diff --git a/.agents/journal/scribe-journal.md b/.agents/journal/scribe-journal.md index e2c206123..70d76763c 100644 --- a/.agents/journal/scribe-journal.md +++ b/.agents/journal/scribe-journal.md @@ -1,45 +1,92 @@ -# Scribe Journal - -## 2025-05-15 - AI Providers Documentation - Completed - -**Verification:** I have thoroughly inspected `clients/agent-runtime/src/providers/mod.rs` and individual provider implementations (e.g., `gemini.rs`, `anthropic.rs`) to identify all supported providers and their environment variables. -**Changes:** -- Updated `clients/web/apps/docs/src/content/docs/clients/agent-runtime/providers/index.mdx` (EN) and `clients/web/apps/docs/src/content/docs/es/clients/agent-runtime/providers/index.mdx` (ES). -- Added missing providers: `OpenAI Codex`, `Synthetic`, `OpenCode Zen`, `Amazon Bedrock`, `LM Studio`. -- Updated environment variables for `Anthropic` (`ANTHROPIC_OAUTH_TOKEN`), `GitHub Copilot` (`GH_TOKEN`), and `Google Gemini` (`GOOGLE_API_KEY`). -- Added "Advanced Authentication" section covering OAuth/CLI reuse for Gemini, Codex, and Copilot, and setup tokens for Anthropic. -**Validation:** Ran `make docs-web-build` and `make docs-web-check`. -**Notes:** -- Confirmed `gemini-cli` OAuth token support in `gemini.rs`. -- Confirmed `ANTHROPIC_OAUTH_TOKEN` support in `mod.rs`. -- Maintained strict bilingual parity between English and Spanish versions. - -## 2025-05-18 - Tools Reference Documentation - Completed - -**Verification:** Audited `clients/agent-runtime/src/tools/` to identify all built-in tools, their parameters, and security tiers. Verified the integration of `agent-browser` and MCP. -**Changes:** -- Created a comprehensive Tools Reference section in both English and Spanish (14 new files). -- Categorized tools into: Core (shell, file_read/write), Web (browser, http_request, search), Memory (store/recall/forget), Automation (git, cron, schedule), Media (screenshot, image_info), and MCP. -- Documented Security Operation Tiers (Safe/Read-Only vs. Risk/Action-Bearing). -- Updated index pages in `docs/clients/agent-runtime/` and `docs/es/clients/agent-runtime/` to link to the new Tools Reference. -**Validation:** -- Ran `make docs-check` and `make docs-build`. 58 pages built successfully. -- Visual verification performed via Playwright for both English and Spanish layouts. -**Notes:** -- Confirmed strict 1:1 parity between `en/` and `es/` directories. -- Technical details like `mcp..` naming convention and `agent-browser` requirements are now documented. - -## 2025-05-22 - Automation and Hardware Tools Reference - Completed - -**Verification:** Audited `clients/agent-runtime/src/tools/` and `clients/agent-runtime/src/peripherals/` to verify parameters and security tiers for all mission-critical tools. Confirmed `delegate` execution modes (OneShot/Session) and verified the full set of `cron_*` tools. -**Changes:** -- Updated `automation.md` (EN/ES) to include `delegate`, `composio`, and complete `cron_*`/`schedule` reference. -- Created `hardware.md` (EN/ES) documenting board discovery (`hardware_board_info`), memory operations (`hardware_memory_map`, `hardware_memory_read`), and peripheral control (`gpio_read`, `gpio_write`, `arduino_upload`). -- Updated `index.mdx` (EN/ES) to include Hardware category and update tool counts/examples. -**Validation:** -- Ran `pnpm --dir clients/web --filter @corvus/locales test` (Passed). -- Ran `./gradlew :web:docsCheck` (Passed: Astro check, Biome lint, Metadata validation). -**Notes:** -- Maintained strict 1:1 parity between English and Spanish. -- Confirmed that `gpio_read`/`gpio_write` are available on both RPi (native) and Uno Q (bridge). -- Documented `arduino_upload` requirements (`arduino-cli`). +# Scribe Documentation Journal + +## 2026-04-12 - Full Documentation Accuracy Audit - Complete + +**Verification:** Systematic comparison of all documentation files in `clients/web/apps/docs/src/content/docs/` (both en/ and es/) against the actual codebase implementation in `clients/agent-runtime/`, `modules/cerebro/`, and root-level build/tooling files. + +### Structure Assessment +- **Bilingual Parity:** ✅ Perfect. 53 files in English (root-level default), 53 files in Spanish (`es/`). 1:1 file mapping with identical directory structures. +- **Note:** The documentation does NOT use an `en/` directory. English docs live at the root of `docs/`, with `es/` as the translation overlay. This is consistent with Starlight's default locale behavior. + +### CLI Reference (`guides/cli-reference.md`) +- **Accuracy:** ✅ High. All documented commands, subcommands, and flags verified against `clients/agent-runtime/src/main.rs` and `composer.rs`. +- **Verified commands:** onboard, agent, code, daemon, gateway, service, doctor, status, cron, models, providers, auth, skills, integrations, channel, hardware, peripheral, migrate, update, cost. +- **Dashboard activation codes:** DASH-001 through DASH-004, DASH-999 — all documented and verified in the onboard/dashboard code. +- **`make dev-up`**: Verified exists at Makefile:308. + +### Cerebro CLI (`cerebro/cli-reference.md`) +- **Accuracy:** ✅ Fully implemented. Verified at `modules/cerebro/src/bin/cerebro.rs`. Two binaries: `cerebro` (full CLI) and `cerebro-serve` (lightweight server). Commands `serve` and `migrate import/validate` match docs exactly. + +### Cerebro Configuration (`cerebro/configuration.md`) +- **Accuracy:** ✅ All config fields, env vars, storage modes, and TUI settings verified against `modules/cerebro/` source. `remote_surreal` correctly marked as "not yet implemented" in both docs and code. + +### Architecture (`clients/agent-runtime/architecture.md`) +- **Accuracy:** ✅ High. All components verified as implemented: + - 22+ providers ✅ (including all regional variants and custom endpoints) + - 14 channels ✅ (CLI, Telegram, Discord, Slack, WhatsApp, Signal, iMessage, Matrix, DingTalk, QQ, Lark, Email, IRC, Mattermost) + - 32+ tools ✅ + - 4 memory backends ✅ (sqlite, lucid, markdown, none) + - 5 observability backends ✅ (noop, log, prometheus, otel, multi) + - Security: Landlock ✅, Bubblewrap ✅, Firejail ✅ (verified in `security/firejail.rs`), Noop ✅ + - **Minor note:** Docs mention "Firejail" in sandboxing section — this IS implemented (77 matches in code). The mermaid diagram says "landlock/firejail/bubblewrap" which is accurate. + - **Capability profiles** (full, code, lite) — verified in bootstrap code. + +### Tools Reference (`clients/agent-runtime/tools/`) +- **Accuracy:** ✅ All tool category pages verified against actual tool implementations in `src/tools/`. +- **Security tiers** (Read-Only vs Action-Bearing) — verified against `security/policy.rs` risk classification. +- **MCP tool runtime** — verified in `tools/mcp/`, correctly documented as gated by `mcp.enabled`. + +### Providers (`clients/agent-runtime/providers/index.mdx`) +- **Accuracy:** ✅ All 22+ providers verified. LM Studio confirmed at `providers/mod.rs:527`. Gemini OAuth and env vars (`GEMINI_API_KEY`, `GOOGLE_API_KEY`) verified in `providers/gemini.rs`. Regional providers (Moonshot, GLM, MiniMax, Qwen, Qianfan, Z.AI) all confirmed. + +### Model Routing (`guides/model-routing.md`) +- **Accuracy:** ✅ `[[model_routes]]` and `[query_classification]` config structures verified against `config/schema.rs`. `corvus doctor` validation warnings verified in doctor module code. `allow_image_input` gate verified. + +### Sandbox Isolation (`guides/runtime-sandbox-isolation.md`) +- **Accuracy:** ✅ All sandbox backends verified: landlock, firejail, bubblewrap, docker, none. `require = true` fail-closed behavior verified. Computer-use sidecar health check (`GET /v1/health`) verified. Audit log fields match implementation. + +### Getting Started (`guides/getting-started.md`) +- **Accuracy:** ✅ Prerequisites match AGENTS.md. `make setup`, `make build`, `make run`, `make test` all verified in Makefile. Dashboard activation flow verified. + +### SurrealDB Guide (`guides/surrealdb.md`) +- **Assessment:** This is a general operational guide for running SurrealDB with Docker Compose. Not specific to Corvus implementation but technically accurate for SurrealDB v3. No discrepancies found. + +### Configuration Options (`guides/configuration.md`) +- **Assessment:** ⚠️ **PARTIAL COVERAGE**. This document focuses heavily on Gradle properties, version catalogs, and MCP configuration. It does NOT cover the full `config.toml` schema for the agent runtime (which is extensive — 30+ sections). The MCP section is accurate. **Recommendation:** This doc should be expanded or split to cover the agent runtime's full configuration surface. + +### Customization (`guides/customization.md`) +- **Assessment:** ⚠️ **OUTDATED**. References `com.profiletailors` as the package namespace and mentions "This repository was created from a Gradle template." This is legacy template language. The "Platform Direction" section mentions "Kotlin + Spring Boot (WebFlux + Coroutines)" and "Neo4j for graph memory" which do NOT appear to be implemented in the current codebase. The actual architecture is Rust-based agent-runtime with Kotlin Multiplatform clients. **This section needs review and likely significant updates.** + +### Features Checklist (`guides/features.md`) +- **Assessment:** ⚠️ **PARTIALLY OUTDATED**. Lists modules as `apps/composeApp`, `apps/androidApp`, etc. but the actual directory structure uses `clients/` prefix (e.g., `clients/composeApp`). Mentions "apps/agent-runtime-rust" but actual path is `clients/agent-runtime`. Missing several implemented features: cost tracking, update system, skills system, cron/scheduler, hardware peripherals, model routing, computer-use sidecar, tunnel providers. **Needs comprehensive update.** + +### Development Procedures (`guides/development.md`) +- **Accuracy:** ✅ Makefile commands verified. `make setup`, `make run`, `make build`, `make test`, `make check`, `make format`, `make clean` all present. Documentation commands (`make docs-web-build`, etc.) verified against Makefile. + +### Structure (`guides/structure.md`) +- **Accuracy:** ✅ Directory structure matches reality. `clients/`, `modules/`, `gradle/` all correctly described. + +### Issues Summary + +| Severity | File | Issue | +|----------|------|-------| +| 🔴 HIGH | `guides/customization.md` | References Spring Boot/Neo4j architecture not in codebase; legacy template language | +| 🔴 HIGH | `guides/features.md` | Wrong module paths (`apps/` vs `clients/`); missing 10+ major features | +| 🟡 MEDIUM | `guides/configuration.md` | Only covers Gradle/MCP; missing full agent runtime config reference | +| 🟢 LOW | `cerebro/` docs | All accurate, but lastReviewed date (2026-04-02) is recent — no action needed | + +### Remediation (2026-04-12) + +All three identified issues have been fixed: + +1. **`guides/customization.md`** — ✅ Fixed. Removed Spring Boot/Neo4j references. Updated with current architecture (Rust agent runtime, KMP clients, Cerebro, web apps). Corrected VERSION to 3.0.0. Added `includeProjects` module registration guide. Both en/es synced. +2. **`guides/features.md`** — ✅ Fixed. Corrected all paths from `apps/` to `clients/`. Expanded from basic checklist to comprehensive coverage: 22+ providers, 14 channels, 32+ tools, memory backends, infrastructure, security, hardware, tunnels. Both en/es synced. +3. **`guides/configuration.md`** — ✅ Improved. Added full agent runtime config reference covering autonomy, security/sandbox, runtime, gateway, memory, agent profiles, model routing, multimodal/audio, scheduler, MCP, observability, cost, updates, skills, and env var overrides. Both en/es synced. + +### Validation Results +- `make docs-check` — ✅ PASSED (astro check + biome + metadata validation: 0 errors, 0 warnings, 8 files validated) +- `make docs-build` — ✅ PASSED (80 pages built, search index generated, no broken links) + +### Notes +- Glossary: "Firejail" = Linux user-space sandbox (confirmed implemented). "Landlock" = Linux kernel-level sandbox (confirmed). "Cerebro" = standalone MCP memory service in `modules/cerebro/` (confirmed). +- Remaining gap: `cost` command subcommands (`summary`, `history`, `reset`) exist in code but are not yet in the CLI reference doc. Low priority. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 2bb287c19..f130d80cc 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -14,7 +14,8 @@ This directory contains all GitHub Actions workflows for the Corvus monorepo. Wo | **Publishing** | `publish-release.yml` | Attach stable artifacts to canonical GitHub Release | `release.published` | | **Publishing** | `publish-snapshot.yml` | Publish Gradle/Maven snapshots only | Manual, daily schedule | | **Publishing** | `release-please.yml` | Create repo-wide release PRs, tags, and canonical GitHub Releases | Push to `main`, manual | -| **Publishing** | `_publish.yml` | Reusable publish workflow | Called by other workflows | +| **Publishing** | `release-please-beta.yml` | Create beta prerelease PRs, tags, and canonical GitHub Releases | Push to `beta`, manual | +| **Publishing** | `_publish.yml` | Reusable stable/beta/snapshot publish workflow | Called by other workflows | | **Automation** | `auto-fix-lockfile.yml` | Auto-update lockfiles | Daily schedule, manual | | **Automation** | `fix-renovate.yml` | Fix lockfiles for Renovate PRs | Comment `/fix-lock` on PR | | **Repo Mgmt** | `git-issue-labeled.yml` | Auto-comments/closes labeled issues | Issue labeled | @@ -179,6 +180,8 @@ Code Scanning. release-please is the canonical owner of the stable GitHub Release and release notes. \_publish.yml exists to attach artifacts to that existing release after `release.published`. +`release-please-beta.yml` owns the canonical beta branch prerelease PRs, tags, GitHub Releases, +and beta release notes. ### `publish-release.yml` - Release Publishing @@ -240,6 +243,33 @@ Calls the reusable `_publish.yml` workflow with: --- +### `release-please-beta.yml` - Beta Release PR Automation + +**Purpose**: Opens or updates the single repo-wide beta prerelease PR from `beta` and owns the +canonical beta prerelease tag, GitHub Release, and release notes. + +**Triggers**: + +- Push to `beta` +- Manual trigger (`workflow_dispatch`) + +**What it does**: + +- Runs release-please with `release-please-beta-config.json` +- Creates or updates a beta prerelease PR with shipped-artifact beta version bumps +- Writes diagnostics to the workflow summary, including the beta manifest baseline and action outputs +- On merge, creates the canonical `vX.Y.Z-beta.N` tag and canonical GitHub prerelease +- Publishes canonical beta GitHub Release notes, then hands beta artifact publication to `_publish.yml` + +**Beta contract**: + +- `release-please-beta.yml` is the canonical owner of beta release PRs, beta tags, beta GitHub Releases, and beta notes. +- Beta releases publish the same shipped artifact set as stable releases. +- npm beta releases use the `beta` dist-tag instead of `latest`. +- Beta Docker publication uses the exact prerelease version plus the moving `beta` tag, and never overwrites stable aliases like `latest`. + +--- + ### `release-please.yml` - Release PR Automation **Purpose**: Opens or updates the single repo-wide stable release PR from `main` and owns the @@ -274,16 +304,18 @@ canonical stable tag, GitHub Release, and release notes. ### `_publish.yml` - Reusable Publishing Workflow -**Purpose**: Internal reusable workflow for publishing release/snapshot artifacts. +**Purpose**: Internal reusable workflow for publishing stable, beta, and snapshot artifacts. -**Called by**: `publish-release.yml`, `publish-snapshot.yml` +**Called by**: `publish-release.yml`, `publish-snapshot.yml`, `release-please-beta.yml` **Inputs**: -| Input | Type | Default | Description | -|-------|------|---------|-------------| -| `release` | boolean | required | Whether the workflow is in stable release mode | -| `release_tag` | string | empty | Canonical stable tag to validate and publish against | -| `release_id` | string | empty | Existing GitHub Release id for asset upload | + +| Input | Type | Default | Description | +| ------------- | ------- | -------- | ----------------------------------------------------- | +| `release` | boolean | required | Whether the workflow is in release mode | +| `prerelease` | boolean | `false` | Whether the release is a beta prerelease | +| `release_tag` | string | empty | Canonical release tag to validate and publish against | +| `release_id` | string | empty | Existing GitHub Release id for asset upload | **Secrets Required**: @@ -302,17 +334,19 @@ canonical stable tag, GitHub Release, and release notes. 2. 🧭 Validates explicit stable release context (`release_tag`, `release_id`) against the existing GitHub Release 3. 👻 Publishes to Maven Central using Gradle 4. 🦀 Publishes Rust crate to crates.io (release only) -5. 📦 Publishes shipped runtime npm packages to npm (release only) -6. 🐳 Builds and publishes multi-arch runtime Docker image to Docker Hub + GHCR (release only) -7. 📊 Builds and publishes multi-arch dashboard Docker image to Docker Hub + GHCR (release only) +5. 📦 Publishes shipped runtime npm packages to npm using `latest` for stable or `beta` for prereleases +6. 🐳 Builds and publishes multi-arch runtime Docker image to Docker Hub + GHCR with stable or beta-safe tags +7. 📊 Builds and publishes multi-arch dashboard Docker image to Docker Hub + GHCR with stable or beta-safe tags 8. 🚀 Attaches assets to the existing canonical GitHub Release **Key Points**: - ⚠️ Warning: Do not use never-expiring User Token for Maven Central - `release-please` owns the canonical public GitHub Release and canonical stable release notes +- `release-please-beta.yml` owns the canonical public GitHub prerelease and canonical beta release notes - `_publish.yml` only uploads assets with `gh release upload --clobber` - Stable publication fans out from `release.published` +- Beta publication fans out from `release-please-beta.yml` - Stable npm publishing excludes `corvus-cli` because it is internal/private - Windows ARM64 is intentionally unsupported for stable npm publication @@ -664,9 +698,9 @@ if: > ## 🔄 Workflow Dependencies ``` -publish-release.yml ─┐ - ├──> _publish.yml (reusable) -publish-snapshot.yml ┘ +publish-release.yml ────────┐ +release-please-beta.yml ├──> _publish.yml (reusable) +publish-snapshot.yml ───────┘ Other workflows call dallay/common-actions: - cleanup-cache.yml diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index f97185efc..393df905e 100644 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -7,6 +7,9 @@ on: release: type: boolean required: true + prerelease: + type: boolean + required: false release_tag: type: string required: false @@ -37,6 +40,10 @@ jobs: release_tag: ${{ steps.release-context.outputs.release_tag }} release_version: ${{ steps.release-context.outputs.release_version }} release_id: ${{ steps.release-context.outputs.release_id }} + release_channel: ${{ steps.release-context.outputs.release_channel }} + release_major_minor: ${{ steps.release-context.outputs.release_major_minor }} + release_major: ${{ steps.release-context.outputs.release_major }} + npm_dist_tag: ${{ steps.release-context.outputs.npm_dist_tag }} runs-on: ubuntu-latest timeout-minutes: 75 permissions: @@ -95,35 +102,39 @@ jobs: - name: 🔧 Show Gradle version run: ./gradlew --version - - name: 🧭 Resolve stable release context + - name: 🧭 Resolve release context if: ${{ inputs.release }} id: release-context env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ inputs.release_tag }} RELEASE_ID: ${{ inputs.release_id }} + PRERELEASE: ${{ inputs.prerelease }} run: | set -euo pipefail if [[ -z "$RELEASE_TAG" || -z "$RELEASE_ID" ]]; then - echo "Stable publishes require release_tag and release_id inputs" - exit 1 - fi - - if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Invalid release tag: $RELEASE_TAG" + echo "Release publishes require release_tag and release_id inputs" exit 1 fi release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}")" - RELEASE_JSON="$release_json" RELEASE_TAG="$RELEASE_TAG" python3 <<'PY' + RELEASE_JSON="$release_json" RELEASE_TAG="$RELEASE_TAG" PRERELEASE="$PRERELEASE" python3 <<'PY' import json import os import pathlib + import re import sys release = json.loads(os.environ["RELEASE_JSON"]) expected_tag = os.environ["RELEASE_TAG"] + is_prerelease = os.environ["PRERELEASE"].lower() == "true" + channel = "beta" if is_prerelease else "stable" + tag_pattern = r"^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$" if is_prerelease else r"^v[0-9]+\.[0-9]+\.[0-9]+$" + + if not re.match(tag_pattern, expected_tag): + print(f"Invalid {channel} release tag: {expected_tag}", file=sys.stderr) + raise SystemExit(1) if release.get("tag_name") != expected_tag: print( @@ -132,34 +143,39 @@ jobs: ) raise SystemExit(1) if release.get("draft"): - print("Stable publish does not accept draft releases", file=sys.stderr) + print(f"{channel.title()} publish does not accept draft releases", file=sys.stderr) raise SystemExit(1) - if release.get("prerelease"): - print("Stable publish does not accept prereleases", file=sys.stderr) + if bool(release.get("prerelease")) != is_prerelease: + expected_state = "a prerelease" if is_prerelease else "a stable release" + print(f"{channel.title()} publish requires {expected_state}", file=sys.stderr) raise SystemExit(1) + version = expected_tag.removeprefix("v") + base_version = version.split("-", 1)[0] + major, minor, _patch = base_version.split(".") + npm_dist_tag = "beta" if is_prerelease else "latest" + output = pathlib.Path(os.environ["GITHUB_OUTPUT"]) with output.open("a", encoding="utf-8") as handle: handle.write(f"release_tag={expected_tag}\n") - handle.write(f"release_version={expected_tag.removeprefix('v')}\n") + handle.write(f"release_version={version}\n") handle.write(f"release_id={release['id']}\n") + handle.write(f"release_channel={channel}\n") + handle.write(f"release_major_minor={major}.{minor}\n") + handle.write(f"release_major={major}\n") + handle.write(f"npm_dist_tag={npm_dist_tag}\n") PY - name: 🔎 Validate release version consistency if: ${{ inputs.release }} - id: stable-version-check + id: release-version-check env: - TAG_VERSION: ${{ steps.release-context.outputs.release_tag }} + EXPECTED_VERSION: ${{ steps.release-context.outputs.release_version }} + RELEASE_CHANNEL: ${{ steps.release-context.outputs.release_channel }} run: | set -euo pipefail - if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Invalid release tag: $TAG_VERSION" - exit 1 - fi - - expected_version="${TAG_VERSION#v}" - EXPECTED_VERSION="$expected_version" python3 <<'PY' + python3 <<'PY' import json import os import pathlib @@ -167,6 +183,7 @@ jobs: import tomllib expected = os.environ["EXPECTED_VERSION"] + channel = os.environ["RELEASE_CHANNEL"].title() def read_properties(path: str, key: str) -> str: for line in pathlib.Path(path).read_text(encoding="utf-8").splitlines(): @@ -219,7 +236,7 @@ jobs: failures = [(surface, actual) for surface, actual in checks if actual != expected] lines = [ - "### Stable version checks", + f"### {channel} version checks", "", f"Expected version: `{expected}`", "", @@ -304,23 +321,29 @@ jobs: - name: 📄 Release publish summary if: ${{ always() && inputs.release }} env: - VERSION_CHECK_OUTCOME: ${{ steps.stable-version-check.outcome }} + RELEASE_CHANNEL: ${{ steps.release-context.outputs.release_channel }} + NPM_DIST_TAG: ${{ steps.release-context.outputs.npm_dist_tag }} + VERSION_CHECK_OUTCOME: ${{ steps.release-version-check.outcome }} CREDENTIALS_OUTCOME: ${{ steps.optional-release-credentials.outcome }} MAVEN_OUTCOME: ${{ steps.publish-maven.outcome }} CARGO_OUTCOME: ${{ steps.publish-cargo.outcome }} run: | { - echo "## Release publish summary" + echo "## ${RELEASE_CHANNEL^} release publish summary" echo + echo "- Channel: ${RELEASE_CHANNEL}" echo "- Incoming tag: ${{ steps.release-context.outputs.release_tag }}" echo "- Existing GitHub Release asset upload target: ${{ steps.release-context.outputs.release_id }}" - echo "- Stable version checks: ${VERSION_CHECK_OUTCOME}" + echo "- ${RELEASE_CHANNEL^} version checks: ${VERSION_CHECK_OUTCOME}" echo "- Optional credential check: ${CREDENTIALS_OUTCOME}" echo "- Maven/Gradle publish step: ${MAVEN_OUTCOME}" echo "- Rust publish step: ${CARGO_OUTCOME}" + echo "- npm dist-tag: ${NPM_DIST_TAG}" echo "- npm stable contract: @dallay/corvus, corvus-darwin-x64, corvus-darwin-arm64, corvus-linux-x64, corvus-linux-arm64, corvus-windows-x64" echo "- Excluded by policy: corvus-cli is internal/private; Windows ARM64 is intentionally unsupported for stable npm publication" - echo "- GitHub Release ownership: release-please owns the canonical GitHub Release and canonical stable release notes; _publish.yml only publishes artifacts/assets" + echo "- Stable notes source: release-please owns canonical stable release notes" + echo "- Beta notes source: release-please owns canonical beta release notes" + echo "- GitHub Release ownership: release-please owns the canonical GitHub Release and canonical ${RELEASE_CHANNEL} release notes; _publish.yml only publishes artifacts/assets" } >> "$GITHUB_STEP_SUMMARY" build-native-binaries: @@ -386,10 +409,11 @@ jobs: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/corvus ghcr.io/${{ github.repository_owner }}/corvus tags: | - type=semver,pattern={{version}},value=${{ needs.publish.outputs.release_tag }} - type=semver,pattern={{major}}.{{minor}},value=${{ needs.publish.outputs.release_tag }} - type=semver,pattern={{major}},value=${{ needs.publish.outputs.release_tag }} - type=raw,value=latest + type=raw,value=${{ needs.publish.outputs.release_version }} + type=raw,value=${{ needs.publish.outputs.release_major_minor }},enable=${{ needs.publish.outputs.release_channel == 'stable' }} + type=raw,value=${{ needs.publish.outputs.release_major }},enable=${{ needs.publish.outputs.release_channel == 'stable' }} + type=raw,value=latest,enable=${{ needs.publish.outputs.release_channel == 'stable' }} + type=raw,value=beta,enable=${{ needs.publish.outputs.release_channel == 'beta' }} - name: 🐳 Build and push Docker image (prebuilt binaries) continue-on-error: true @@ -456,10 +480,11 @@ jobs: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/cerebro ghcr.io/${{ github.repository_owner }}/cerebro tags: | - type=semver,pattern={{version}},value=${{ needs.publish.outputs.release_tag }} - type=semver,pattern={{major}}.{{minor}},value=${{ needs.publish.outputs.release_tag }} - type=semver,pattern={{major}},value=${{ needs.publish.outputs.release_tag }} - type=raw,value=latest + type=raw,value=${{ needs.publish.outputs.release_version }} + type=raw,value=${{ needs.publish.outputs.release_major_minor }},enable=${{ needs.publish.outputs.release_channel == 'stable' }} + type=raw,value=${{ needs.publish.outputs.release_major }},enable=${{ needs.publish.outputs.release_channel == 'stable' }} + type=raw,value=latest,enable=${{ needs.publish.outputs.release_channel == 'stable' }} + type=raw,value=beta,enable=${{ needs.publish.outputs.release_channel == 'beta' }} - name: 🐳 Build and push Cerebro Docker image continue-on-error: true @@ -547,6 +572,7 @@ jobs: id: publish-platform-package env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_DIST_TAG: ${{ needs.publish.outputs.npm_dist_tag }} run: | set -euo pipefail if [ -z "$NODE_AUTH_TOKEN" ]; then @@ -573,18 +599,25 @@ jobs: exit 0 fi - npm publish --access public + npm_publish_args=(--access public) + if [ "${NPM_DIST_TAG:-latest}" != "latest" ]; then + npm_publish_args+=(--tag "$NPM_DIST_TAG") + fi + + npm publish "${npm_publish_args[@]}" - name: 📄 Summarize npm platform publish if: ${{ always() }} env: PLATFORM_PUBLISH_OUTCOME: ${{ steps.publish-platform-package.outcome }} + NPM_DIST_TAG: ${{ needs.publish.outputs.npm_dist_tag }} run: | { echo "## npm platform publish summary" echo echo "- Package: @dallay/${{ matrix.pkg }}" echo "- Outcome: ${PLATFORM_PUBLISH_OUTCOME}" + echo "- Dist-tag: ${NPM_DIST_TAG}" echo "- Stable publish matrix excludes corvus-cli because it is internal/private." echo "- Windows ARM64 is intentionally unsupported and not part of the stable npm publish matrix." } >> "$GITHUB_STEP_SUMMARY" @@ -608,6 +641,7 @@ jobs: id: publish-base-package env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_DIST_TAG: ${{ needs.publish.outputs.npm_dist_tag }} run: | set -euo pipefail if [ -z "$NODE_AUTH_TOKEN" ]; then @@ -625,18 +659,25 @@ jobs: exit 0 fi - npm publish --access public + npm_publish_args=(--access public) + if [ "${NPM_DIST_TAG:-latest}" != "latest" ]; then + npm_publish_args+=(--tag "$NPM_DIST_TAG") + fi + + npm publish "${npm_publish_args[@]}" - name: 📄 Summarize npm base publish if: ${{ always() }} env: BASE_PUBLISH_OUTCOME: ${{ steps.publish-base-package.outcome }} + NPM_DIST_TAG: ${{ needs.publish.outputs.npm_dist_tag }} run: | { echo "## npm base publish summary" echo echo "- Package: @dallay/corvus" echo "- Outcome: ${BASE_PUBLISH_OUTCOME}" + echo "- Dist-tag: ${NPM_DIST_TAG}" echo "- Stable optionalDependencies only reference shipped platforms." echo "- Windows ARM64 is intentionally unsupported and excluded from stable npm publication." } >> "$GITHUB_STEP_SUMMARY" @@ -693,13 +734,15 @@ jobs: - name: 📄 Summarize GitHub Release publication if: ${{ always() }} env: + RELEASE_CHANNEL: ${{ needs.publish.outputs.release_channel }} RELEASE_ASSETS_OUTCOME: ${{ steps.upload-release-assets.outcome }} run: | { echo "## GitHub Release publication" echo + echo "- Channel: ${RELEASE_CHANNEL}" echo "- Outcome: ${RELEASE_ASSETS_OUTCOME}" echo "- Canonical public release record: existing GitHub Release for ${{ needs.publish.outputs.release_tag }}" echo "- Existing GitHub Release asset upload: gh release upload --clobber" - echo "- Notes source: release-please owns canonical stable release notes; _publish.yml must not replace them" + echo "- Notes source: release-please owns canonical ${RELEASE_CHANNEL} release notes; _publish.yml must not replace them" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/dashboard-accessibility.yml b/.github/workflows/dashboard-accessibility.yml new file mode 100644 index 000000000..434f8f6bd --- /dev/null +++ b/.github/workflows/dashboard-accessibility.yml @@ -0,0 +1,71 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Dashboard Accessibility + +on: + pull_request: + branches: + - main + - minor + paths: + - ".github/workflows/dashboard-accessibility.yml" + - "clients/web/pnpm-lock.yaml" + - "clients/web/pnpm-workspace.yaml" + - "clients/web/apps/dashboard/**" + - "clients/web/packages/**" + push: + branches: + - main + - minor + paths: + - ".github/workflows/dashboard-accessibility.yml" + - "clients/web/pnpm-lock.yaml" + - "clients/web/pnpm-workspace.yaml" + - "clients/web/apps/dashboard/**" + - "clients/web/packages/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + dashboard-a11y: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: ✈ Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + with: + version: 10 + + - name: 📦 Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: clients/web/pnpm-lock.yaml + + - name: 📥 Install web workspace dependencies + run: pnpm install --frozen-lockfile + working-directory: clients/web + + - name: 🎭 Install Playwright Chromium + run: pnpm --filter @corvus/dashboard run test:e2e:install + working-directory: clients/web + + - name: ♿ Run dashboard axe unit smoke tests + run: pnpm --filter @corvus/dashboard run test:a11y + working-directory: clients/web + + - name: ♿ Run dashboard accessibility smoke e2e + run: pnpm --filter @corvus/dashboard run test:e2e:a11y + working-directory: clients/web diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index a9926a365..a22cdd0cc 100755 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -23,6 +23,7 @@ jobs: uses: ./.github/workflows/_publish.yml with: release: true + prerelease: false release_tag: ${{ github.event.release.tag_name }} release_id: ${{ github.event.release.id }} secrets: diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 2269ae9e6..3329e5206 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -21,6 +21,7 @@ jobs: with: # Snapshot runs only cover the Gradle/Maven channel. release: false + prerelease: false secrets: SIGNING_IN_MEMORY_KEY: ${{ secrets.SIGNING_IN_MEMORY_KEY }} SIGNING_IN_MEMORY_KEY_PASSWORD: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} diff --git a/.github/workflows/release-please-beta.yml b/.github/workflows/release-please-beta.yml new file mode 100644 index 000000000..22694f15a --- /dev/null +++ b/.github/workflows/release-please-beta.yml @@ -0,0 +1,152 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Release Please Beta + +on: + push: + branches: + - beta + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: false + +jobs: + release-please-beta: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.ref == 'refs/heads/beta' || (github.event_name == 'workflow_dispatch' && github.event.ref == 'beta') + permissions: + contents: write + pull-requests: write + issues: write + outputs: + release_created: ${{ steps.release-please.outputs.releases_created || steps.release-please.outputs.release_created || 'false' }} + tag_name: ${{ steps.release-please.outputs.tag_name }} + release_id: ${{ steps.resolve-release-id.outputs.release_id }} + steps: + - name: 🔐 Generate release GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: 📥 Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: 🤖 Run release-please + id: release-please + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-beta-config.json + manifest-file: .release-please-beta-manifest.json + target-branch: beta + fork: false + + - name: 🧾 Resolve GitHub Release id + if: ${{ steps.release-please.outputs.releases_created == 'true' || steps.release-please.outputs.release_created == 'true' }} + id: resolve-release-id + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_TAG: ${{ steps.release-please.outputs.tag_name }} + run: | + set -euo pipefail + release_id="" + max_attempts=5 + attempt=0 + while [ $attempt -lt $max_attempts ]; do + attempt=$((attempt + 1)) + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id' 2>/dev/null || true)" + if [ -n "$release_id" ]; then + break + fi + if [ $attempt -lt $max_attempts ]; then + sleep_seconds=$((attempt * 2)) + echo "Attempt $attempt failed, retrying in ${sleep_seconds}s..." + sleep $sleep_seconds + fi + done + if [ -z "$release_id" ]; then + echo "::error::Failed to resolve release id for tag ${RELEASE_TAG} after ${max_attempts} attempts" + exit 1 + fi + echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" + + - name: 📄 Summarize release-please outcome + if: ${{ always() }} + env: + RELEASE_PLEASE_OUTPUTS: ${{ toJson(steps.release-please.outputs) }} + run: | + set -euo pipefail + python3 <<'PY' + import json + import os + from pathlib import Path + + outputs = json.loads(os.environ.get("RELEASE_PLEASE_OUTPUTS") or "{}") + manifest = json.loads(Path(".release-please-beta-manifest.json").read_text(encoding="utf-8")) + manifest_version = manifest.get(".", "unknown") + + version = outputs.get("version") or "not emitted" + tag_name = outputs.get("tag_name") or "not emitted" + releases_created = outputs.get("releases_created") or outputs.get("release_created") or "false" + prs_created = outputs.get("prs_created") or "false" + pr_metadata = "present" if outputs.get("pr") or outputs.get("prs") else "absent" + auth_source = "GitHub App token via APP_ID/APP_PRIVATE_KEY" + + lines = [ + "## beta release-please action outputs", + "", + "- Prerelease release PR/tag/GitHub Release path: `release-please` from merges to `beta`", + "- Canonical beta GitHub Release notes owner: `release-please`", + f"- Auth source: `{auth_source}`", + f"- Manifest baseline: `{manifest_version}`", + f"- Candidate version output: `{version}`", + f"- Tag output: `{tag_name}`", + f"- PR created in this run: `{prs_created}`", + f"- Release PR metadata available: `{pr_metadata}`", + f"- Beta release created in this run: `{releases_created}`", + "", + "
", + "Raw release-please outputs", + "", + "```json", + json.dumps(outputs, indent=2, sort_keys=True), + "```", + "
", + ] + + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + PY + + publish-beta: + needs: release-please-beta + if: >- + (github.ref == 'refs/heads/beta' || (github.event_name == 'workflow_dispatch' && github.event.ref == 'beta')) && + needs.release-please-beta.outputs.release_created == 'true' && + needs.release-please-beta.outputs.tag_name != '' && + needs.release-please-beta.outputs.release_id != '' + uses: ./.github/workflows/_publish.yml + permissions: + contents: write + packages: write + with: + release: true + prerelease: true + release_tag: ${{ needs.release-please-beta.outputs.tag_name }} + release_id: ${{ needs.release-please-beta.outputs.release_id }} + secrets: + SIGNING_IN_MEMORY_KEY: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + SIGNING_IN_MEMORY_KEY_PASSWORD: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 31ed56fe0..2a7b8d542 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -55,7 +55,25 @@ jobs: RELEASE_TAG: ${{ steps.release-please.outputs.tag_name }} run: | set -euo pipefail - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id')" + release_id="" + max_attempts=5 + attempt=0 + while [ $attempt -lt $max_attempts ]; do + attempt=$((attempt + 1)) + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id' 2>/dev/null || true)" + if [ -n "$release_id" ]; then + break + fi + if [ $attempt -lt $max_attempts ]; then + sleep_seconds=$((attempt * 2)) + echo "Attempt $attempt failed, retrying in ${sleep_seconds}s..." + sleep $sleep_seconds + fi + done + if [ -z "$release_id" ]; then + echo "::error::Failed to resolve release id for tag ${RELEASE_TAG} after ${max_attempts} attempts" + exit 1 + fi echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" - name: 📄 Summarize release-please outcome @@ -118,6 +136,7 @@ jobs: packages: write with: release: true + prerelease: false release_tag: ${{ needs.release-please.outputs.tag_name }} release_id: ${{ needs.release-please.outputs.release_id }} secrets: diff --git a/.gitignore b/.gitignore index b306aa89e..71b24d6f2 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,8 @@ cypress/downloads/ # ------------------------------------------------------------------------------ coverage/ *.lcov +test-results/ +.playwright-mcp/ # ------------------------------------------------------------------------------ # TEMPORARY FILES AND LOGS diff --git a/.release-please-beta-manifest.json b/.release-please-beta-manifest.json new file mode 100644 index 000000000..d4f6f2994 --- /dev/null +++ b/.release-please-beta-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "3.0.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5039cb7..4bee6066f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.0.0](https://github.com/dallay/corvus/compare/v2.4.0...v3.0.0) (2026-04-12) +## [3.0.0](https://github.com/dallay/corvus/compare/v2.3.1...v3.0.0) (2026-04-12) ### ⚠ BREAKING CHANGES @@ -249,7 +249,7 @@ * Enhance Docker setup for dashboard with improved config bindings ([#151](https://github.com/dallay/corvus/issues/151)) ([0a64bb3](https://github.com/dallay/corvus/commit/0a64bb33d86bd9cdd5e71331760d8f1d28b22e41)) * **web:** destructure composable returns for template auto-unwrapping ([#456](https://github.com/dallay/corvus/issues/456)) ([fa4d07b](https://github.com/dallay/corvus/commit/fa4d07b98e2e804b7669f85141ed327de09d593f)) -## [2.4.0](https://github.com/dallay/corvus/compare/v2.3.0...v2.4.0) (2026-04-11) +## [2.3.1](https://github.com/dallay/corvus/compare/v2.3.0...v2.3.1) (2026-04-11) ### Features diff --git a/clients/agent-runtime/Cargo.lock b/clients/agent-runtime/Cargo.lock index 2c45afba5..f7be0f69c 100644 --- a/clients/agent-runtime/Cargo.lock +++ b/clients/agent-runtime/Cargo.lock @@ -1159,6 +1159,7 @@ dependencies = [ "corvus-channels", "corvus-composer", "corvus-memory", + "corvus-observability", "corvus-providers", "corvus-security", "corvus-tools", @@ -1245,6 +1246,7 @@ dependencies = [ "anyhow", "corvus-channels", "corvus-memory", + "corvus-observability", "corvus-providers", "corvus-security", "corvus-tools", @@ -1260,6 +1262,13 @@ dependencies = [ "corvus-traits", ] +[[package]] +name = "corvus-observability" +version = "0.1.0" +dependencies = [ + "anyhow", +] + [[package]] name = "corvus-providers" version = "0.1.0" @@ -6972,9 +6981,9 @@ dependencies = [ [[package]] name = "surrealdb-librocksdb-sys" -version = "0.18.0+11.0.0" +version = "0.18.1+11.0.0-2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920c08d2ccd5befe4fcb377dc2c31547c31a09e7e00e0429bf7dd6a7a82de91b" +checksum = "c87fb5a0a71b9c48f5511132d117f08c2b97918c965b9a5c5ba859754af13cf6" dependencies = [ "bindgen", "bzip2-sys", diff --git a/clients/agent-runtime/Cargo.toml b/clients/agent-runtime/Cargo.toml index ecc3c139b..d1602e65c 100644 --- a/clients/agent-runtime/Cargo.toml +++ b/clients/agent-runtime/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/corvus-traits", "crates/corvus-providers", "crates/corvus-channels", "crates/corvus-tools", "crates/corvus-memory", "crates/corvus-security", "crates/corvus-composer", "crates/robot-kit"] +members = [".", "crates/corvus-traits", "crates/corvus-providers", "crates/corvus-channels", "crates/corvus-tools", "crates/corvus-memory", "crates/corvus-observability", "crates/corvus-security", "crates/corvus-composer", "crates/robot-kit"] resolver = "2" [package] @@ -89,6 +89,7 @@ corvus-providers = { path = "crates/corvus-providers" } corvus-channels = { path = "crates/corvus-channels" } corvus-tools = { path = "crates/corvus-tools" } corvus-memory = { path = "crates/corvus-memory" } +corvus-observability = { path = "crates/corvus-observability" } corvus-security = { path = "crates/corvus-security" } corvus-composer = { path = "crates/corvus-composer" } diff --git a/clients/agent-runtime/crates/corvus-channels/src/factory.rs b/clients/agent-runtime/crates/corvus-channels/src/factory.rs new file mode 100644 index 000000000..b7af06ade --- /dev/null +++ b/clients/agent-runtime/crates/corvus-channels/src/factory.rs @@ -0,0 +1,119 @@ +use anyhow::{anyhow, Result}; + +use crate::registry::{channel_availability, resolve_channel_key, CapabilityAvailability}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChannelFactorySelection { + pub key: &'static str, +} + +pub fn select_channel(name: &str) -> Result { + let Some(key) = resolve_channel_key(name) else { + return Err(anyhow!("unknown channel '{name}'")); + }; + + match channel_availability(key) { + Some(CapabilityAvailability::Constructible) => Ok(ChannelFactorySelection { key }), + Some(CapabilityAvailability::Uncompiled) => { + Err(anyhow!("channel '{key}' is known but not compiled")) + } + Some(CapabilityAvailability::PlatformUnavailable) => { + Err(anyhow!("channel '{key}' is unavailable on this platform")) + } + None => Err(anyhow!("unknown channel '{name}'")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_channel_returns_ok_for_constructible_channel() { + let result = select_channel("stdio"); + assert!(result.is_ok(), "expected Ok for 'stdio', got: {result:?}"); + assert_eq!(result.unwrap().key, "stdio"); + } + + #[test] + fn select_channel_returns_ok_for_multiple_always_on_channels() { + for name in &["telegram", "discord", "slack", "mattermost", "matrix"] { + let result = select_channel(name); + assert!( + result.is_ok(), + "expected Ok for '{name}', got: {result:?}" + ); + } + } + + #[test] + fn select_channel_key_is_canonical_static_str() { + // Input can be uppercase; returned key must be the lowercase canonical key. + let selection = select_channel("STDIO").unwrap(); + assert_eq!(selection.key, "stdio"); + } + + #[test] + fn select_channel_err_for_completely_unknown_name() { + let result = select_channel("not-a-channel"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("unknown channel"), + "error message was: {msg}" + ); + } + + #[test] + fn select_channel_err_for_empty_string() { + let result = select_channel(""); + assert!(result.is_err()); + } + + #[test] + fn select_channel_err_with_not_compiled_for_webhook() { + // webhook is compiled=false in the registry → Uncompiled + let result = select_channel("webhook"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("not compiled"), + "expected 'not compiled' in error, got: {msg}" + ); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn select_channel_err_platform_unavailable_for_imessage_on_non_macos() { + let result = select_channel("imessage"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("unavailable on this platform"), + "expected platform-unavailable error, got: {msg}" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn select_channel_ok_for_imessage_on_macos() { + let result = select_channel("imessage"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().key, "imessage"); + } + + #[test] + fn channel_factory_selection_is_copy() { + let sel = ChannelFactorySelection { key: "stdio" }; + let copy = sel; + assert_eq!(sel, copy); + } + + // Regression: selecting a channel twice should produce identical results. + #[test] + fn select_channel_is_idempotent() { + let first = select_channel("discord").unwrap(); + let second = select_channel("discord").unwrap(); + assert_eq!(first, second); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-channels/src/lib.rs b/clients/agent-runtime/crates/corvus-channels/src/lib.rs index b1e1356a8..a51463fa0 100644 --- a/clients/agent-runtime/crates/corvus-channels/src/lib.rs +++ b/clients/agent-runtime/crates/corvus-channels/src/lib.rs @@ -1,12 +1,11 @@ -//! Corvus Channels Registry -//! -//! Re-exports channel types and provides registry functions. +//! Corvus channel registry surfaces for manifest composition. -pub use corvus_traits::channels::{Channel, ChannelMessage, ContentPart, SendMessage}; +pub mod factory; +pub mod registry; -/// Information about a channel. -#[derive(Debug, Clone)] -pub struct ChannelInfo { - pub name: &'static str, - pub display_name: &'static str, -} +pub use corvus_traits::channels::{Channel, ChannelMessage, ContentPart, SendMessage}; +pub use factory::{select_channel, ChannelFactorySelection}; +pub use registry::{ + channel_availability, list_channels, resolve_channel_key, CapabilityAvailability, + ChannelDescriptor, +}; diff --git a/clients/agent-runtime/crates/corvus-channels/src/registry.rs b/clients/agent-runtime/crates/corvus-channels/src/registry.rs new file mode 100644 index 000000000..843b349ae --- /dev/null +++ b/clients/agent-runtime/crates/corvus-channels/src/registry.rs @@ -0,0 +1,328 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAvailability { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChannelDescriptor { + pub key: &'static str, + pub display_name: &'static str, + pub aliases: &'static [&'static str], + pub compiled: bool, + pub platform_supported: bool, +} + +const CHANNELS: &[ChannelDescriptor] = &[ + ChannelDescriptor { + key: "stdio", + display_name: "Standard IO", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "telegram", + display_name: "Telegram", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "discord", + display_name: "Discord", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "slack", + display_name: "Slack", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "mattermost", + display_name: "Mattermost", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "imessage", + display_name: "iMessage", + aliases: &[], + compiled: cfg!(target_os = "macos"), + platform_supported: cfg!(target_os = "macos"), + }, + ChannelDescriptor { + key: "matrix", + display_name: "Matrix", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "signal", + display_name: "Signal", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "whatsapp", + display_name: "WhatsApp", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "email", + display_name: "Email", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "irc", + display_name: "IRC", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "lark", + display_name: "Lark", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "dingtalk", + display_name: "DingTalk", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "qq", + display_name: "QQ", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ChannelDescriptor { + key: "webhook", + display_name: "Webhook", + aliases: &[], + // Placeholder: webhook channel is not yet implemented (deferred). + // callers of channel_availability() receive Uncompiled for this key. + compiled: false, + platform_supported: true, + }, +]; + +pub fn list_channels() -> &'static [ChannelDescriptor] { + CHANNELS +} + +pub fn resolve_channel_key(name: &str) -> Option<&'static str> { + let candidate = name.trim(); + CHANNELS + .iter() + .find(|descriptor| { + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) + .map(|descriptor| descriptor.key) +} + +pub fn channel_availability(name: &str) -> Option { + let key = resolve_channel_key(name)?; + CHANNELS + .iter() + .find(|descriptor| descriptor.key == key) + .map(|descriptor| { + if !descriptor.platform_supported { + CapabilityAvailability::PlatformUnavailable + } else if !descriptor.compiled { + CapabilityAvailability::Uncompiled + } else { + CapabilityAvailability::Constructible + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- list_channels --- + + #[test] + fn list_channels_is_non_empty() { + assert!(!list_channels().is_empty()); + } + + #[test] + fn list_channels_includes_all_expected_keys() { + let channels = list_channels(); + let keys: Vec<&str> = channels.iter().map(|c| c.key).collect(); + for expected in &[ + "stdio", "telegram", "discord", "slack", "mattermost", "imessage", "matrix", + "signal", "whatsapp", "email", "irc", "lark", "dingtalk", "qq", "webhook", + ] { + assert!( + keys.contains(expected), + "expected channel key '{expected}' not found in registry" + ); + } + } + + #[test] + fn list_channels_has_unique_keys() { + let channels = list_channels(); + let mut seen = std::collections::HashSet::new(); + for descriptor in channels { + assert!( + seen.insert(descriptor.key), + "duplicate channel key: '{}'", + descriptor.key + ); + } + } + + #[test] + fn all_channel_descriptors_have_non_empty_display_name() { + for descriptor in list_channels() { + assert!( + !descriptor.display_name.is_empty(), + "channel '{}' has empty display_name", + descriptor.key + ); + } + } + + // --- resolve_channel_key --- + + #[test] + fn resolve_channel_key_returns_none_for_unknown() { + assert_eq!(resolve_channel_key("not-a-real-channel"), None); + assert_eq!(resolve_channel_key(""), None); + } + + #[test] + fn resolve_channel_key_matches_exact_key() { + assert_eq!(resolve_channel_key("stdio"), Some("stdio")); + assert_eq!(resolve_channel_key("telegram"), Some("telegram")); + assert_eq!(resolve_channel_key("webhook"), Some("webhook")); + } + + #[test] + fn resolve_channel_key_is_case_insensitive() { + assert_eq!(resolve_channel_key("STDIO"), Some("stdio")); + assert_eq!(resolve_channel_key("Telegram"), Some("telegram")); + assert_eq!(resolve_channel_key("DISCORD"), Some("discord")); + assert_eq!(resolve_channel_key("Webhook"), Some("webhook")); + } + + #[test] + fn resolve_channel_key_trims_leading_and_trailing_whitespace() { + assert_eq!(resolve_channel_key(" stdio "), Some("stdio")); + assert_eq!(resolve_channel_key("\ttelegram\n"), Some("telegram")); + } + + #[test] + fn resolve_channel_key_returns_canonical_static_key() { + // The returned key must be the same static reference, not user-supplied casing. + let key = resolve_channel_key("SLACK").unwrap(); + assert_eq!(key, "slack"); + } + + // --- channel_availability --- + + #[test] + fn channel_availability_returns_none_for_unknown() { + assert_eq!(channel_availability("does-not-exist"), None); + } + + #[test] + fn channel_availability_constructible_for_always_on_channels() { + for name in &["stdio", "telegram", "discord", "slack", "mattermost"] { + assert_eq!( + channel_availability(name), + Some(CapabilityAvailability::Constructible), + "expected Constructible for '{name}'" + ); + } + } + + #[test] + fn channel_availability_uncompiled_for_webhook() { + // webhook is explicitly marked compiled=false in the registry. + assert_eq!( + channel_availability("webhook"), + Some(CapabilityAvailability::Uncompiled) + ); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn channel_availability_platform_unavailable_for_imessage_on_non_macos() { + assert_eq!( + channel_availability("imessage"), + Some(CapabilityAvailability::PlatformUnavailable) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn channel_availability_constructible_for_imessage_on_macos() { + assert_eq!( + channel_availability("imessage"), + Some(CapabilityAvailability::Constructible) + ); + } + + #[test] + fn channel_availability_is_case_insensitive() { + assert_eq!( + channel_availability("STDIO"), + Some(CapabilityAvailability::Constructible) + ); + assert_eq!( + channel_availability("Webhook"), + Some(CapabilityAvailability::Uncompiled) + ); + } + + // --- CapabilityAvailability enum --- + + #[test] + fn capability_availability_variants_are_eq_comparable() { + assert_eq!( + CapabilityAvailability::Constructible, + CapabilityAvailability::Constructible + ); + assert_ne!( + CapabilityAvailability::Constructible, + CapabilityAvailability::Uncompiled + ); + assert_ne!( + CapabilityAvailability::Uncompiled, + CapabilityAvailability::PlatformUnavailable + ); + } + + // Regression: whitespace-only input must not match any channel. + #[test] + fn resolve_channel_key_whitespace_only_returns_none() { + assert_eq!(resolve_channel_key(" "), None); + assert_eq!(resolve_channel_key("\t"), None); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-composer/Cargo.toml b/clients/agent-runtime/crates/corvus-composer/Cargo.toml index 71019cc87..b8f842526 100644 --- a/clients/agent-runtime/crates/corvus-composer/Cargo.toml +++ b/clients/agent-runtime/crates/corvus-composer/Cargo.toml @@ -12,6 +12,7 @@ corvus-providers = { path = "../corvus-providers" } corvus-channels = { path = "../corvus-channels" } corvus-tools = { path = "../corvus-tools" } corvus-memory = { path = "../corvus-memory" } +corvus-observability = { path = "../corvus-observability" } corvus-security = { path = "../corvus-security" } anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } diff --git a/clients/agent-runtime/crates/corvus-composer/src/lib.rs b/clients/agent-runtime/crates/corvus-composer/src/lib.rs index a73c7dd93..a892a1b5a 100644 --- a/clients/agent-runtime/crates/corvus-composer/src/lib.rs +++ b/clients/agent-runtime/crates/corvus-composer/src/lib.rs @@ -1,906 +1,267 @@ -//! Corvus Composer - Agent Manifest and Composer -//! -//! Provides the Agent Manifest TOML schema and `AgentComposer` for -//! composing agents from manifests using the existing runtime registries. +mod manifest; +mod plan; +mod registry_snapshot; +mod resolver; -use anyhow::{Context, Result}; - -use serde::{Deserialize, Serialize}; - -// ============================================================================= -// Agent Manifest Schema (TOML) -// ============================================================================= - -/// Agent Manifest - defines agent composition via TOML -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentManifest { - /// Manifest version (required) - pub version: String, - - /// Agent name (required) - pub name: String, - - /// Agent description - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Provider configuration - pub providers: ProviderSection, - - /// Channel configuration - pub channels: ChannelSection, - - /// Tools configuration - pub tools: ToolsSection, - - /// Memory configuration - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory: Option, - - /// Observer configuration - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observer: Option, - - /// Security configuration - #[serde(default, skip_serializing_if = "Option::is_none")] - pub security: Option, -} - -/// Provider section - at least one must be enabled -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderSection { - /// List of provider names (required, at least one) - pub providers: Vec, - - /// Default provider (required, must be in providers list) - pub default: String, - - /// Model to use - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - - /// Temperature setting - #[serde(default, skip_serializing_if = "Option::is_none")] - pub temperature: Option, -} - -/// Channel section - at least one must be enabled -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChannelSection { - /// List of channel names (required, at least one) - pub channels: Vec, - - /// Default channel - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -/// Tools section configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsSection { - /// List of tool names (required) - pub tools: Vec, +pub use manifest::*; +pub use plan::*; +pub use registry_snapshot::*; +pub use resolver::*; - /// Tool mode: "allow" or "deny" - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mode: Option, -} - -/// Memory section configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemorySection { - /// Memory backend: "sqlite", "none", etc. - pub backend: String, - - /// Additional config - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config: Option, -} - -/// Observer section -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ObserverSection { - /// Observer name - pub name: String, - - /// Config - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config: Option, -} - -/// Security section -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecuritySection { - /// Sandbox backend: "wasmi", "landlock", etc. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - - /// Tool restrictions (subset of enabled tools) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_restrictions: Option>, -} - -// ============================================================================= -// Validation Errors -// ============================================================================= - -/// Validation errors for manifests and capability reports -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ValidationError { - /// No providers configured - NoProviders, - /// No channels configured - NoChannels, - /// Default provider not in enabled list - DefaultProviderDisabled { name: String }, - /// Unknown capability referenced - UnknownCapability { name: String, kind: String }, - /// Invalid memory backend - InvalidMemoryBackend { backend: String }, - /// Invalid sandbox backend - InvalidSandboxBackend { sandbox: String }, - /// Tool restrictions must be subset of enabled tools - ToolRestrictionsNotSubset { - restrictions: Vec, - enabled: Vec, - }, - /// Inline secrets not allowed - InlineSecret { field: String }, - /// No tools configured - NoTools, - /// No default channel specified - NoDefaultChannel, -} - -impl std::fmt::Display for ValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ValidationError::NoProviders => write!(f, "at least one provider must be configured"), - ValidationError::NoChannels => write!(f, "at least one channel must be configured"), - ValidationError::DefaultProviderDisabled { name } => { - write!(f, "default provider '{}' must be enabled", name) - } - ValidationError::UnknownCapability { name, kind } => { - write!(f, "unknown {} capability '{}'", kind, name) - } - ValidationError::InvalidMemoryBackend { backend } => { - write!(f, "invalid memory backend '{}'", backend) - } - ValidationError::InvalidSandboxBackend { sandbox } => { - write!(f, "invalid sandbox backend '{}'", sandbox) - } - ValidationError::ToolRestrictionsNotSubset { - restrictions, - enabled, - } => { - write!( - f, - "tool restrictions {:?} must be subset of enabled tools {:?}", - restrictions, enabled - ) - } - ValidationError::InlineSecret { field } => { - write!( - f, - "inline secret not allowed in '{}'; use environment variable references", - field - ) - } - ValidationError::NoTools => write!(f, "at least one tool must be configured"), - ValidationError::NoDefaultChannel => write!( - f, - "default channel must be specified when multiple channels are configured" - ), - } - } -} - -impl std::error::Error for ValidationError {} - -// ============================================================================= -// Capability Report -// ============================================================================= - -/// Report of required capabilities for an agent -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CapabilityReport { - /// Required provider names - pub providers: Vec, - /// Required channel names - pub channels: Vec, - /// Required tool names - pub tools: Vec, - /// Required memory backend (if specified) - pub memory_backend: Option, - /// Required observer (if specified) - pub observer: Option, - /// Required sandbox (if specified) - pub sandbox: Option, -} +use anyhow::{Context, Result}; +use std::path::Path; -// ============================================================================= -// Known Capabilities (from registries) -// ============================================================================= - -/// Known provider names (registered in the runtime) -pub const KNOWN_PROVIDERS: &[&str] = &[ - "anthropic", - "openai", - "openrouter", - "google", - "gemini", - "azure", - "cerebro", - "ollama", - "xai", -]; - -/// Known channel names -pub const KNOWN_CHANNELS: &[&str] = &["telegram", "discord", "slack", "webhook", "stdio"]; - -/// Known tool names -pub const KNOWN_TOOLS: &[&str] = &[ - "shell", - "file_read", - "file_write", - "browser", - "http_request", - "memory_recall", - "memory_store", - "memory_forget", - "web_search_tool", - "code_search", - "git_operations", - "image_info", - "screenshot", - "browser_open", - "delegate", - "composio", - "cron_add", - "cron_list", - "cron_remove", - "cron_run", - "cron_runs", - "cron_update", - "pushover", - "schedule", - "hardware_board_info", - "hardware_memory_map", - "hardware_memory_read", -]; - -/// Known memory backends -pub const KNOWN_MEMORY_BACKENDS: &[&str] = &["sqlite", "none"]; - -/// Known sandbox backends -pub const KNOWN_SANDBOX_BACKENDS: &[&str] = &["wasmi", "landlock", "bubblewrap", "none"]; - -// ============================================================================= -// Agent Composer -// ============================================================================= - -/// Agent Composer - parses and validates manifests, resolves components from registries +#[derive(Debug, Clone)] pub struct AgentComposer { manifest: AgentManifest, - reports: CapabilityReport, -} - -/// Reasons why a validation might warn (non-fatal) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ValidationWarning { - pub field: String, - pub message: String, + snapshot: RegistrySnapshot, + plan: ComposedRuntimePlan, } impl AgentComposer { - /// Create a new composer from a manifest pub fn from_manifest(manifest: AgentManifest) -> Result { - let composer = Self { - manifest: manifest.clone(), - reports: CapabilityReport { - providers: manifest.providers.providers.clone(), - channels: manifest.channels.channels.clone(), - tools: manifest.tools.tools.clone(), - memory_backend: manifest.memory.as_ref().map(|m| m.backend.clone()), - observer: manifest.observer.as_ref().map(|o| o.name.clone()), - sandbox: manifest.security.as_ref().and_then(|s| s.sandbox.clone()), - }, - }; - - // Validate and return - composer.validate()?; - Ok(composer) + let snapshot = RegistrySnapshot::collect(); + let plan = resolve_manifest(&manifest, &snapshot).map_err(anyhow::Error::from)?; + Ok(Self { + manifest, + snapshot, + plan, + }) } - /// Parse a manifest from TOML string pub fn from_toml(toml_str: &str) -> Result { - let manifest: AgentManifest = - toml::from_str(toml_str).context("failed to parse manifest TOML")?; + let manifest = parse_manifest(toml_str)?; Self::from_manifest(manifest) } - /// Parse a manifest from a file path - pub fn from_path(path: &std::path::Path) -> Result { + pub fn from_path(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read manifest from {}", path.display()))?; Self::from_toml(&content) } - /// Validate the manifest per PRD requirements (R1-R7) - pub fn validate(&self) -> Result<(), ValidationError> { - // R1: At least one provider must be enabled - if self.manifest.providers.providers.is_empty() { - return Err(ValidationError::NoProviders); - } - - // R2: At least one channel must be enabled - if self.manifest.channels.channels.is_empty() { - return Err(ValidationError::NoChannels); - } - - // R3: Default provider must be enabled - let default = &self.manifest.providers.default; - if !self.manifest.providers.providers.contains(default) { - return Err(ValidationError::DefaultProviderDisabled { - name: default.clone(), - }); - } - - // R4: Enabled capabilities must be known - for provider in &self.manifest.providers.providers { - if !KNOWN_PROVIDERS.contains(&provider.as_str()) { - return Err(ValidationError::UnknownCapability { - name: provider.clone(), - kind: "provider".to_string(), - }); - } - } - - for channel in &self.manifest.channels.channels { - if !KNOWN_CHANNELS.contains(&channel.as_str()) { - return Err(ValidationError::UnknownCapability { - name: channel.clone(), - kind: "channel".to_string(), - }); - } - } - - for tool in &self.manifest.tools.tools { - if !KNOWN_TOOLS.contains(&tool.as_str()) { - return Err(ValidationError::UnknownCapability { - name: tool.clone(), - kind: "tool".to_string(), - }); - } - } - - // R5: Memory backend must be valid - if let Some(memory) = &self.manifest.memory { - if !KNOWN_MEMORY_BACKENDS.contains(&memory.backend.as_str()) { - return Err(ValidationError::InvalidMemoryBackend { - backend: memory.backend.clone(), - }); - } - } - - // R6: Security sandbox must be valid - if let Some(security) = &self.manifest.security { - if let Some(sandbox) = &security.sandbox { - if !sandbox.is_empty() && !KNOWN_SANDBOX_BACKENDS.contains(&sandbox.as_str()) { - return Err(ValidationError::InvalidSandboxBackend { - sandbox: sandbox.clone(), - }); - } - } - - // R7: Tool restrictions must be subset of enabled tools - if let Some(restrictions) = &security.tool_restrictions { - if !restrictions.is_empty() { - let enabled: Vec<&str> = self - .manifest - .tools - .tools - .iter() - .map(|s| s.as_str()) - .collect(); - for r in restrictions { - if !enabled.contains(&r.as_str()) { - return Err(ValidationError::ToolRestrictionsNotSubset { - restrictions: restrictions.clone(), - enabled: self.manifest.tools.tools.clone(), - }); - } - } - } - } - } - - // Note: We cannot check inline secrets until we have access to resolve - // environment variables at runtime - the manifest parsing itself won't contain - // resolved secrets, they'll be resolved during composer execution - - Ok(()) + pub fn manifest(&self) -> &AgentManifest { + &self.manifest } - /// Validate with warnings (non-fatal issues that don't block composition) - pub fn validate_with_warnings(&self) -> Vec { - let mut warnings = Vec::new(); - - // Check for potentially missing tools - if self.manifest.tools.tools.is_empty() { - warnings.push(ValidationWarning { - field: "tools.tools".to_string(), - message: "no tools configured - agent will have limited capabilities".to_string(), - }); - } - - // Check for no memory - if self.manifest.memory.is_none() { - warnings.push(ValidationWarning { - field: "memory".to_string(), - message: "no memory configured - agent will not persist conversation history" - .to_string(), - }); - } - - // Check for multiple channels without default - if self.manifest.channels.channels.len() > 1 && self.manifest.channels.default.is_none() { - warnings.push(ValidationWarning { - field: "channels.default".to_string(), - message: "multiple channels configured but no default set".to_string(), - }); - } - - warnings + pub fn registry_snapshot(&self) -> &RegistrySnapshot { + &self.snapshot } - /// Get the required capabilities report - pub fn required_capabilities(&self) -> &CapabilityReport { - &self.reports + pub fn validate(&self) -> std::result::Result<(), ValidationError> { + resolve_manifest(&self.manifest, &self.snapshot).map(|_| ()) } - /// Get the manifest - pub fn manifest(&self) -> &AgentManifest { - &self.manifest + pub fn resolve_plan(&self) -> &ComposedRuntimePlan { + &self.plan } - /// Build a composed agent using the existing AgentBuilder - /// - /// Note: This produces a configured AgentBuilder that can produce an Agent. - /// The actual construction would need access to the component registries - /// (providers, channels, tools, memory, observer, security) which are - /// typically available via the runtime's bootstrap process. - pub fn into_agent_builder(self) -> Result { - // This is a placeholder - the actual implementation would - // resolve components from registries and build using AgentBuilder - Err(anyhow::anyhow!( - "registry resolution not implemented in corvus-composer; use bootstrap module" - )) + pub fn required_capabilities(&self) -> CapabilityReport { + required_capabilities(&self.plan) } } -/// Placeholder trait for AgentBuilder integration -/// -/// In practice, this would integrate with the existing AgentBuilder from corvus -/// via the main corvus crate. -pub trait AgentBuilderTrait { - fn new() -> Self; -} - -// ============================================================================= -// Tests -// ============================================================================= - #[cfg(test)] mod tests { use super::*; - #[test] - fn parse_minimal_manifest() { - let toml = r#" -version = "1.0" -name = "test-agent" + fn valid_manifest() -> &'static str { + r#" +version = "1" + +[agent] +name = "code-agent" +description = "Composed code agent" +model = "anthropic/claude-sonnet-4" +temperature = 0.2 +profile = "code" [providers] -providers = ["anthropic"] +enabled = ["anthropic"] default = "anthropic" +[providers.config.anthropic] +api_url = "https://example.test" + [channels] -channels = ["telegram"] +enabled = ["stdio"] +default = "stdio" + +[channels.config.stdio] +mode = "interactive" [tools] -tools = ["shell", "file_read"] -"#; - let composer = AgentComposer::from_toml(toml).unwrap(); - assert_eq!(composer.manifest().name, "test-agent"); - } +enabled = ["shell", "file_read"] - #[test] - fn validate_requires_providers() { - let toml = r#" -version = "1.0" -name = "test-agent" +[tools.config.shell] +mode = "allow" -[providers] -providers = [] -default = "" +[memory] +backend = "markdown" -[channels] -channels = ["telegram"] +[memory.config.markdown] +kind = "workspace" -[tools] -tools = ["shell"] -"#; - let result = AgentComposer::from_toml(toml); - assert!(result.is_err()); - } +[observability] +enabled = ["log"] - #[test] - fn validate_requires_channels() { - let toml = r#" -version = "1.0" -name = "test-agent" +[observability.config.log] +format = "compact" -[providers] -providers = ["anthropic"] -default = "anthropic" +[security] +backend = "none" +tool_restrictions = ["shell"] -[channels] -channels = [] +[security.config.none] +strategy = "compat" -[tools] -tools = ["shell"] -"#; - let result = AgentComposer::from_toml(toml); - assert!(result.is_err()); +[runtime] +max_tool_iterations = 4 + +[identity] +format = "openclaw" +"# } #[test] - fn validate_requires_default_in_providers() { - let toml = r#" -version = "1.0" -name = "test-agent" - -[providers] -providers = ["anthropic"] -default = "openai" + fn valid_manifest_resolves_prd_v1_plan() { + let composer = AgentComposer::from_toml(valid_manifest()).unwrap(); + let plan = composer.resolve_plan(); -[channels] -channels = ["telegram"] - -[tools] -tools = ["shell"] -"#; - let result = AgentComposer::from_toml(toml); - assert!(result.is_err()); + assert_eq!(plan.agent.name, "code-agent"); + assert_eq!(plan.provider.key, "anthropic"); + assert_eq!(plan.channels[0].key, "stdio"); + assert_eq!( + plan.tools + .iter() + .map(|tool| tool.key.as_str()) + .collect::>(), + vec!["shell", "file_read"] + ); + assert_eq!(plan.memory.key, "markdown"); + assert_eq!(plan.observers[0].key, "log"); + assert_eq!(plan.security.key, "none"); + assert_eq!(plan.runtime.max_tool_iterations, Some(4)); + assert_eq!( + plan.provider + .config + .as_ref() + .and_then(|value| value.get("api_url")) + .and_then(toml::Value::as_str), + Some("https://example.test") + ); + assert!(plan.channels[0].config.as_ref().is_some()); } #[test] - fn validate_unknown_provider() { - let toml = r#" -version = "1.0" -name = "test-agent" - -[providers] -providers = ["unknown-provider"] -default = "unknown-provider" - -[channels] -channels = ["telegram"] - -[tools] -tools = ["shell"] -"#; - let result = AgentComposer::from_toml(toml); - assert!(result.is_err()); + fn default_provider_must_be_enabled() { + let manifest = valid_manifest().replace("default = \"anthropic\"", "default = \"openai\""); + let error = AgentComposer::from_toml(&manifest).unwrap_err(); + assert!(error + .to_string() + .contains("default provider 'openai' must be enabled")); } #[test] - fn validate_tool_restrictions_subset() { - let toml = r#" -version = "1.0" -name = "test-agent" - -[providers] -providers = ["anthropic"] -default = "anthropic" - -[channels] -channels = ["telegram"] - -[tools] -tools = ["shell", "file_read"] - -[security] -tool_restrictions = ["shell", "unknown_tool"] -"#; - let result = AgentComposer::from_toml(toml); - assert!(result.is_err()); + fn missing_required_family_selection_fails_before_composition() { + let manifest = valid_manifest().replace("enabled = [\"anthropic\"]", "enabled = []"); + let error = AgentComposer::from_toml(&manifest).unwrap_err(); + assert!(error + .to_string() + .contains("must select at least one provider")); } #[test] - fn required_capabilities() { - let toml = r#" -version = "1.0" -name = "test-agent" + fn unknown_capability_failure_is_distinct() { + let manifest = r#" +version = "1" + +[agent] +name = "unknown-provider" [providers] -providers = ["anthropic", "openrouter"] -default = "anthropic" +enabled = ["totally-unknown-provider"] +default = "totally-unknown-provider" [channels] -channels = ["telegram", "discord"] - -[tools] -tools = ["shell", "file_read"] +enabled = ["stdio"] [memory] -backend = "sqlite" - -[observer] -name = "prometheus" - -[security] -sandbox = "wasmi" +backend = "none" "#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let report = composer.required_capabilities(); - - assert_eq!(report.providers, vec!["anthropic", "openrouter"]); - assert_eq!(report.channels, vec!["telegram", "discord"]); - assert_eq!(report.tools, vec!["shell", "file_read"]); - assert_eq!(report.memory_backend, Some("sqlite".to_string())); - assert_eq!(report.observer, Some("prometheus".to_string())); - assert_eq!(report.sandbox, Some("wasmi".to_string())); + let error = AgentComposer::from_toml(manifest).unwrap_err(); + assert!(error + .to_string() + .contains("unknown provider capability 'totally-unknown-provider'")); } #[test] - fn warnings_for_empty_tools() { - let toml = r#" -version = "1.0" -name = "test-agent" + fn family_mismatch_is_rejected_deterministically() { + let manifest = r#" +version = "1" + +[agent] +name = "bad-family" [providers] -providers = ["anthropic"] -default = "anthropic" +enabled = ["shell"] +default = "shell" [channels] -channels = ["telegram"] +enabled = ["stdio"] -[tools] -tools = [] +[memory] +backend = "none" "#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let warnings = composer.validate_with_warnings(); - - assert!(warnings.iter().any(|w| w.field == "tools.tools")); - } - - // ------------------------------------------------------------------------- - // Template compliance tests — parse every bundled template and validate - // ------------------------------------------------------------------------- - - fn template_path(name: &str) -> std::path::PathBuf { - // CARGO_MANIFEST_DIR is set to the crate root at test time. - // Templates live two levels up: crates/corvus-composer -> agent-runtime root. - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - std::path::Path::new(&manifest_dir) - .join("../..") - .join("agents/templates") - .join(name) - } - - fn assert_template_valid(filename: &str) { - let path = template_path(filename); - let content = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("cannot read template {filename}: {e}")); - let composer = AgentComposer::from_toml(&content) - .unwrap_or_else(|e| panic!("template {filename} failed validation: {e}")); - assert!( - !composer.manifest().name.is_empty(), - "template {filename} must have a non-empty name" - ); - } - - #[test] - fn template_minimal_is_valid() { - assert_template_valid("minimal.toml"); - } - - #[test] - fn template_chat_bot_is_valid() { - assert_template_valid("chat-bot.toml"); - } - - #[test] - fn template_support_agent_is_valid() { - assert_template_valid("support-agent.toml"); - } - - #[test] - fn template_code_assistant_is_valid() { - assert_template_valid("code-assistant.toml"); - } - - #[test] - fn template_research_agent_is_valid() { - assert_template_valid("research-agent.toml"); - } - - #[test] - fn template_ops_agent_is_valid() { - assert_template_valid("ops-agent.toml"); + let error = AgentComposer::from_toml(manifest).unwrap_err(); + assert!(error.to_string().contains("belongs to family 'tool'")); } #[test] - fn templates_have_unique_names() { - let filenames = [ - "minimal.toml", - "chat-bot.toml", - "support-agent.toml", - "code-assistant.toml", - "research-agent.toml", - "ops-agent.toml", - ]; - let names: Vec = filenames - .iter() - .map(|f| { - let path = template_path(f); - let content = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("cannot read {f}: {e}")); - let composer = AgentComposer::from_toml(&content) - .unwrap_or_else(|e| panic!("{f} parse error: {e}")); - composer.manifest().name.clone() - }) - .collect(); - let unique: std::collections::HashSet<_> = names.iter().collect(); - assert_eq!( - names.len(), - unique.len(), - "template agent names must be unique; found duplicates in: {names:?}" + fn uncompiled_capability_failure_is_distinct() { + let manifest = valid_manifest().replace( + "enabled = [\"shell\", \"file_read\"]", + "enabled = [\"shell\", \"mcp.dynamic\"]", ); + let error = AgentComposer::from_toml(&manifest).unwrap_err(); + assert!(error.to_string().contains("known but not compiled")); } #[test] - fn template_ops_agent_has_security_sandbox() { - let path = template_path("ops-agent.toml"); - let content = std::fs::read_to_string(&path).unwrap(); - let composer = AgentComposer::from_toml(&content).unwrap(); - let security = composer - .manifest() - .security - .as_ref() - .expect("ops-agent must have a [security] section"); - let sandbox = security.sandbox.as_deref().unwrap_or("none"); - assert_ne!(sandbox, "", "ops-agent sandbox must be explicitly set"); - } - - #[test] - fn template_minimal_has_no_tools() { - let path = template_path("minimal.toml"); - let content = std::fs::read_to_string(&path).unwrap(); - let composer = AgentComposer::from_toml(&content).unwrap(); - assert!( - composer.manifest().tools.tools.is_empty(), - "minimal template should have no tools" - ); - } - - // ------------------------------------------------------------------------- - // Differential: AgentComposer capability report matches manifest declarations - // ------------------------------------------------------------------------- - - #[test] - fn differential_required_capabilities_match_provider_section() { - let toml = r#" -version = "1.0" -name = "diff-test-agent" -[providers] -providers = ["anthropic", "openai"] -default = "anthropic" -[channels] -channels = ["telegram"] -[tools] -tools = ["shell"] -"#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let caps = composer.required_capabilities(); - assert!(caps.providers.contains(&"anthropic".to_string())); - assert!(caps.providers.contains(&"openai".to_string())); - } + fn platform_unavailable_failure_is_distinct() { + // Test that PlatformUnavailableCapability is distinct from other errors. + // On Linux, use a capability that reports as unavailable to force the path. + let manifest = r#" +version = "1" - #[test] - fn differential_required_capabilities_match_channel_section() { - let toml = r#" -version = "1.0" -name = "diff-channels-agent" -[providers] -providers = ["gemini"] -default = "gemini" -[channels] -channels = ["discord", "slack"] -[tools] -tools = [] -"#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let caps = composer.required_capabilities(); - assert!(caps.channels.contains(&"discord".to_string())); - assert!(caps.channels.contains(&"slack".to_string())); - } +[agent] +name = "platform-test" - #[test] - fn differential_required_capabilities_match_tools_section() { - let toml = r#" -version = "1.0" -name = "diff-tools-agent" [providers] -providers = ["anthropic"] +enabled = ["anthropic"] default = "anthropic" -[channels] -channels = ["telegram"] -[tools] -tools = ["shell", "file_read", "browser"] -"#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let caps = composer.required_capabilities(); - assert!(caps.tools.contains(&"shell".to_string())); - assert!(caps.tools.contains(&"file_read".to_string())); - assert!(caps.tools.contains(&"browser".to_string())); - } - #[test] - fn differential_empty_tools_produces_no_tool_capabilities() { - let toml = r#" -version = "1.0" -name = "minimal-diff-agent" -[providers] -providers = ["anthropic"] -default = "anthropic" [channels] -channels = ["telegram"] -[tools] -tools = [] -"#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let caps = composer.required_capabilities(); - assert!(caps.tools.is_empty()); - } +enabled = ["webhook"] - #[test] - fn differential_memory_capability_present_when_configured() { - let toml = r#" -version = "1.0" -name = "memory-diff-agent" -[providers] -providers = ["anthropic"] -default = "anthropic" -[channels] -channels = ["telegram"] -[tools] -tools = [] [memory] -backend = "sqlite" +backend = "none" + +[security] +backend = "firejail" "#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let caps = composer.required_capabilities(); - assert!(caps.memory_backend.is_some()); + // webhook channel is marked as compiled: false in registry, + // which triggers PlatformUnavailableCapability on all platforms. + let error = AgentComposer::from_toml(manifest).unwrap_err(); + assert!(error.to_string().contains("unavailable on this platform")); } #[test] - fn differential_no_memory_capability_when_not_configured() { - let toml = r#" -version = "1.0" -name = "no-memory-diff-agent" -[providers] -providers = ["anthropic"] -default = "anthropic" -[channels] -channels = ["telegram"] -[tools] -tools = [] -"#; - let composer = AgentComposer::from_toml(toml).unwrap(); - let caps = composer.required_capabilities(); - assert!(caps.memory_backend.is_none()); + fn config_for_unselected_capability_is_rejected() { + let manifest = valid_manifest().replace("[tools.config.shell]", "[tools.config.browser]"); + let error = AgentComposer::from_toml(&manifest).unwrap_err(); + assert!(error + .to_string() + .contains("configuration exists for an unselected capability")); } } diff --git a/clients/agent-runtime/crates/corvus-composer/src/manifest.rs b/clients/agent-runtime/crates/corvus-composer/src/manifest.rs new file mode 100644 index 000000000..b347d8f25 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-composer/src/manifest.rs @@ -0,0 +1,297 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentManifest { + pub version: String, + pub agent: AgentSection, + pub providers: ProviderFamilySection, + pub channels: ChannelFamilySection, + #[serde(default)] + pub tools: ToolFamilySection, + pub memory: MemorySection, + #[serde(default)] + pub observability: ObservabilitySection, + #[serde(default)] + pub security: SecuritySection, + #[serde(default)] + pub runtime: RuntimeSection, + #[serde(default)] + pub identity: IdentitySection, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSection { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub profile: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderFamilySection { + pub enabled: Vec, + pub default: String, + #[serde(default)] + pub config: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelFamilySection { + pub enabled: Vec, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub config: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ToolFamilySection { + #[serde(default)] + pub enabled: Vec, + #[serde(default)] + pub config: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemorySection { + pub backend: String, + #[serde(default)] + pub config: BTreeMap, + #[serde(default)] + pub auto_save: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ObservabilitySection { + #[serde(default)] + pub enabled: Vec, + #[serde(default)] + pub config: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecuritySection { + #[serde(default = "default_security_backend")] + pub backend: String, + #[serde(default)] + pub config: BTreeMap, + #[serde(default)] + pub require: Option, + #[serde(default)] + pub tool_restrictions: Vec, +} + +pub(crate) fn default_security_backend() -> String { + "auto".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RuntimeSection { + #[serde(default)] + pub profile: Option, + #[serde(default)] + pub max_tool_iterations: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IdentitySection { + #[serde(default)] + pub format: Option, + #[serde(default)] + pub aieos_path: Option, + #[serde(default)] + pub aieos_inline: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_toml() -> &'static str { + r#" +version = "1" + +[agent] +name = "minimal" + +[providers] +enabled = ["anthropic"] +default = "anthropic" + +[channels] +enabled = ["stdio"] + +[memory] +backend = "none" +"# + } + + // --- AgentManifest parsing --- + + #[test] + fn parse_minimal_manifest_succeeds() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert_eq!(manifest.version, "1"); + assert_eq!(manifest.agent.name, "minimal"); + } + + #[test] + fn parse_agent_optional_fields_default_to_none() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert!(manifest.agent.description.is_none()); + assert!(manifest.agent.model.is_none()); + assert!(manifest.agent.temperature.is_none()); + assert!(manifest.agent.profile.is_none()); + } + + #[test] + fn parse_provider_family_section_populated() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert_eq!(manifest.providers.enabled, vec!["anthropic"]); + assert_eq!(manifest.providers.default, "anthropic"); + assert!(manifest.providers.config.is_empty()); + } + + #[test] + fn parse_channel_family_section_populated() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert_eq!(manifest.channels.enabled, vec!["stdio"]); + assert!(manifest.channels.default.is_none()); + } + + #[test] + fn parse_memory_section_populated() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert_eq!(manifest.memory.backend, "none"); + assert!(manifest.memory.config.is_empty()); + assert!(manifest.memory.auto_save.is_none()); + } + + // --- SecuritySection default --- + + #[test] + fn security_section_default_backend_is_auto() { + // When [security] is not present at all, backend must default to "auto" + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert_eq!(manifest.security.backend, "auto"); + } + + #[test] + fn security_section_explicit_backend_overrides_default() { + let toml_str = format!( + "{}\n[security]\nbackend = \"none\"\n", + minimal_toml() + ); + let manifest: AgentManifest = toml::from_str(&toml_str).unwrap(); + assert_eq!(manifest.security.backend, "none"); + } + + #[test] + fn security_section_tool_restrictions_default_empty() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert!(manifest.security.tool_restrictions.is_empty()); + } + + // --- ToolFamilySection defaults --- + + #[test] + fn tool_family_section_defaults_to_empty_enabled_list() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert!(manifest.tools.enabled.is_empty()); + } + + #[test] + fn tool_family_section_derive_default_works() { + let tools = ToolFamilySection::default(); + assert!(tools.enabled.is_empty()); + assert!(tools.config.is_empty()); + } + + // --- ObservabilitySection defaults --- + + #[test] + fn observability_section_defaults_to_empty_enabled_list() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert!(manifest.observability.enabled.is_empty()); + } + + #[test] + fn observability_section_derive_default_works() { + let obs = ObservabilitySection::default(); + assert!(obs.enabled.is_empty()); + assert!(obs.config.is_empty()); + } + + // --- RuntimeSection defaults --- + + #[test] + fn runtime_section_defaults_are_none() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert!(manifest.runtime.profile.is_none()); + assert!(manifest.runtime.max_tool_iterations.is_none()); + } + + // --- IdentitySection defaults --- + + #[test] + fn identity_section_defaults_are_none() { + let manifest: AgentManifest = toml::from_str(minimal_toml()).unwrap(); + assert!(manifest.identity.format.is_none()); + assert!(manifest.identity.aieos_path.is_none()); + assert!(manifest.identity.aieos_inline.is_none()); + } + + // --- Config BTreeMap parsing --- + + #[test] + fn provider_config_map_is_parsed_correctly() { + let toml_str = r#" +version = "1" +[agent] +name = "config-test" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[providers.config.anthropic] +api_url = "https://example.test" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +"#; + let manifest: AgentManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.providers.config.contains_key("anthropic")); + } + + // Boundary: agent with all optional fields set + #[test] + fn parse_full_agent_section() { + let toml_str = r#" +version = "1" +[agent] +name = "full-agent" +description = "A complete agent" +model = "anthropic/claude-3" +temperature = 0.7 +profile = "code" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "sqlite" +"#; + let manifest: AgentManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.agent.description.as_deref(), Some("A complete agent")); + assert_eq!(manifest.agent.model.as_deref(), Some("anthropic/claude-3")); + assert_eq!(manifest.agent.temperature, Some(0.7)); + assert_eq!(manifest.agent.profile.as_deref(), Some("code")); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-composer/src/plan.rs b/clients/agent-runtime/crates/corvus-composer/src/plan.rs new file mode 100644 index 000000000..2b4dda4aa --- /dev/null +++ b/clients/agent-runtime/crates/corvus-composer/src/plan.rs @@ -0,0 +1,264 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SelectedCapability { + pub key: String, + pub config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeSettings { + pub profile: Option, + pub max_tool_iterations: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MemorySettings { + pub auto_save: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IdentitySettings { + pub format: Option, + pub aieos_path: Option, + pub aieos_inline: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentMetadata { + pub name: String, + pub description: Option, + pub model: Option, + pub temperature: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ComposedRuntimePlan { + pub agent: AgentMetadata, + pub provider: SelectedCapability, + pub channels: Vec, + pub default_channel: Option, + pub tools: Vec, + pub memory: SelectedCapability, + pub memory_settings: MemorySettings, + pub observers: Vec, + pub security: SelectedCapability, + pub tool_restrictions: Vec, + pub runtime: RuntimeSettings, + pub identity: IdentitySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CapabilityReport { + pub providers: Vec, + pub channels: Vec, + pub tools: Vec, + pub memory_backend: Option, + pub observers: Vec, + pub sandbox: Option, +} + +impl From<&ComposedRuntimePlan> for CapabilityReport { + fn from(plan: &ComposedRuntimePlan) -> Self { + Self { + providers: vec![plan.provider.key.clone()], + channels: plan.channels.iter().map(|item| item.key.clone()).collect(), + tools: plan.tools.iter().map(|item| item.key.clone()).collect(), + memory_backend: Some(plan.memory.key.clone()), + observers: plan.observers.iter().map(|item| item.key.clone()).collect(), + sandbox: Some(plan.security.key.clone()), + } + } +} + +pub type CapabilityConfigMap = BTreeMap; + +#[cfg(test)] +mod tests { + use super::*; + + fn make_plan( + provider_key: &str, + channel_keys: &[&str], + tool_keys: &[&str], + memory_key: &str, + observer_keys: &[&str], + security_key: &str, + ) -> ComposedRuntimePlan { + ComposedRuntimePlan { + agent: AgentMetadata { + name: "test-agent".to_string(), + description: None, + model: None, + temperature: None, + }, + provider: SelectedCapability { + key: provider_key.to_string(), + config: None, + }, + channels: channel_keys + .iter() + .map(|k| SelectedCapability { + key: k.to_string(), + config: None, + }) + .collect(), + default_channel: channel_keys.first().map(|k| k.to_string()), + tools: tool_keys + .iter() + .map(|k| SelectedCapability { + key: k.to_string(), + config: None, + }) + .collect(), + memory: SelectedCapability { + key: memory_key.to_string(), + config: None, + }, + memory_settings: MemorySettings { auto_save: None }, + observers: observer_keys + .iter() + .map(|k| SelectedCapability { + key: k.to_string(), + config: None, + }) + .collect(), + security: SelectedCapability { + key: security_key.to_string(), + config: None, + }, + tool_restrictions: vec![], + runtime: RuntimeSettings { + profile: None, + max_tool_iterations: None, + }, + identity: IdentitySettings { + format: None, + aieos_path: None, + aieos_inline: None, + }, + } + } + + // --- CapabilityReport::from --- + + #[test] + fn capability_report_from_plan_has_correct_provider() { + let plan = make_plan("anthropic", &["stdio"], &[], "sqlite", &[], "none"); + let report = CapabilityReport::from(&plan); + assert_eq!(report.providers, vec!["anthropic"]); + } + + #[test] + fn capability_report_from_plan_maps_all_channels() { + let plan = make_plan( + "anthropic", + &["stdio", "telegram", "discord"], + &[], + "none", + &[], + "none", + ); + let report = CapabilityReport::from(&plan); + assert_eq!(report.channels, vec!["stdio", "telegram", "discord"]); + } + + #[test] + fn capability_report_from_plan_maps_all_tools() { + let plan = make_plan( + "anthropic", + &["stdio"], + &["shell", "file_read", "browser"], + "none", + &[], + "none", + ); + let report = CapabilityReport::from(&plan); + assert_eq!(report.tools, vec!["shell", "file_read", "browser"]); + } + + #[test] + fn capability_report_from_plan_always_has_memory_backend() { + let plan = make_plan("anthropic", &["stdio"], &[], "sqlite", &[], "none"); + let report = CapabilityReport::from(&plan); + assert_eq!(report.memory_backend, Some("sqlite".to_string())); + } + + #[test] + fn capability_report_from_plan_maps_observers() { + let plan = make_plan("anthropic", &["stdio"], &[], "none", &["log", "prometheus"], "none"); + let report = CapabilityReport::from(&plan); + assert_eq!(report.observers, vec!["log", "prometheus"]); + } + + #[test] + fn capability_report_from_plan_empty_observers_produces_empty_vec() { + let plan = make_plan("anthropic", &["stdio"], &[], "none", &[], "none"); + let report = CapabilityReport::from(&plan); + assert!(report.observers.is_empty()); + } + + #[test] + fn capability_report_from_plan_always_has_sandbox() { + let plan = make_plan("anthropic", &["stdio"], &[], "none", &[], "landlock"); + let report = CapabilityReport::from(&plan); + assert_eq!(report.sandbox, Some("landlock".to_string())); + } + + #[test] + fn capability_report_from_plan_empty_tools_produces_empty_vec() { + let plan = make_plan("anthropic", &["stdio"], &[], "none", &[], "none"); + let report = CapabilityReport::from(&plan); + assert!(report.tools.is_empty()); + } + + // --- SelectedCapability --- + + #[test] + fn selected_capability_equality() { + let a = SelectedCapability { + key: "shell".to_string(), + config: None, + }; + let b = SelectedCapability { + key: "shell".to_string(), + config: None, + }; + assert_eq!(a, b); + } + + #[test] + fn selected_capability_inequality_on_key() { + let a = SelectedCapability { + key: "shell".to_string(), + config: None, + }; + let b = SelectedCapability { + key: "file_read".to_string(), + config: None, + }; + assert_ne!(a, b); + } + + // --- RuntimeSettings --- + + #[test] + fn runtime_settings_with_all_fields() { + let settings = RuntimeSettings { + profile: Some("code".to_string()), + max_tool_iterations: Some(10), + }; + assert_eq!(settings.profile.as_deref(), Some("code")); + assert_eq!(settings.max_tool_iterations, Some(10)); + } + + // Boundary: provider is always a single entry in the report + #[test] + fn capability_report_providers_is_single_element_vec() { + let plan = make_plan("openai", &["stdio"], &[], "none", &[], "none"); + let report = CapabilityReport::from(&plan); + assert_eq!(report.providers.len(), 1); + assert_eq!(report.providers[0], "openai"); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-composer/src/registry_snapshot.rs b/clients/agent-runtime/crates/corvus-composer/src/registry_snapshot.rs new file mode 100644 index 000000000..eee90eecb --- /dev/null +++ b/clients/agent-runtime/crates/corvus-composer/src/registry_snapshot.rs @@ -0,0 +1,420 @@ +use corvus_channels::{channel_availability, list_channels}; +use corvus_memory::{list_memory_backends, memory_availability}; +use corvus_observability::{list_observers, observer_availability}; +use corvus_providers::{list_providers, provider_availability}; +use corvus_security::{list_sandboxes, sandbox_availability}; +use corvus_tools::{list_tools, tool_availability}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityFamily { + Provider, + Channel, + Tool, + Memory, + Observer, + Security, +} + +impl CapabilityFamily { + pub fn as_str(self) -> &'static str { + match self { + Self::Provider => "provider", + Self::Channel => "channel", + Self::Tool => "tool", + Self::Memory => "memory", + Self::Observer => "observer", + Self::Security => "security", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityStatus { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityRecord { + pub family: CapabilityFamily, + pub key: &'static str, + pub aliases: &'static [&'static str], + pub status: CapabilityStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistrySnapshot { + pub records: Vec, +} + +impl RegistrySnapshot { + pub fn collect() -> Self { + let mut records = Vec::new(); + records.extend(list_providers().iter().map(|descriptor| CapabilityRecord { + family: CapabilityFamily::Provider, + key: descriptor.key, + aliases: descriptor.aliases, + status: map_provider_status(descriptor.key), + })); + records.extend(list_channels().iter().map(|descriptor| CapabilityRecord { + family: CapabilityFamily::Channel, + key: descriptor.key, + aliases: descriptor.aliases, + status: map_channel_status(descriptor.key), + })); + records.extend(list_tools().iter().map(|descriptor| CapabilityRecord { + family: CapabilityFamily::Tool, + key: descriptor.key, + aliases: descriptor.aliases, + status: map_tool_status(descriptor.key), + })); + records.extend( + list_memory_backends() + .iter() + .map(|descriptor| CapabilityRecord { + family: CapabilityFamily::Memory, + key: descriptor.key, + aliases: descriptor.aliases, + status: map_memory_status(descriptor.key), + }), + ); + records.extend(list_observers().iter().map(|descriptor| CapabilityRecord { + family: CapabilityFamily::Observer, + key: descriptor.key, + aliases: descriptor.aliases, + status: map_observer_status(descriptor.key), + })); + records.extend(list_sandboxes().iter().map(|descriptor| CapabilityRecord { + family: CapabilityFamily::Security, + key: descriptor.key, + aliases: descriptor.aliases, + status: map_security_status(descriptor.key), + })); + Self { records } + } + + pub fn find_in_family( + &self, + family: CapabilityFamily, + requested: &str, + ) -> Option<&CapabilityRecord> { + self.records.iter().find(|record| { + record.family == family + && (record.key.eq_ignore_ascii_case(requested) + || record + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(requested))) + }) + } + + pub fn find_in_other_family( + &self, + family: CapabilityFamily, + requested: &str, + ) -> Option<&CapabilityRecord> { + self.records.iter().find(|record| { + record.family != family + && (record.key.eq_ignore_ascii_case(requested) + || record + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(requested))) + }) + } +} + +fn map_provider_status(key: &str) -> CapabilityStatus { + map_status(provider_availability(key)) +} + +fn map_channel_status(key: &str) -> CapabilityStatus { + map_status(channel_availability(key)) +} + +fn map_tool_status(key: &str) -> CapabilityStatus { + map_status(tool_availability(key)) +} + +fn map_memory_status(key: &str) -> CapabilityStatus { + map_status(memory_availability(key)) +} + +fn map_observer_status(key: &str) -> CapabilityStatus { + map_status(observer_availability(key)) +} + +fn map_security_status(key: &str) -> CapabilityStatus { + map_status(sandbox_availability(key)) +} + +fn map_status(status: Option) -> CapabilityStatus +where + T: IntoComposerStatus, +{ + status + .map(IntoComposerStatus::into_composer_status) + .unwrap_or(CapabilityStatus::Uncompiled) +} + +trait IntoComposerStatus { + fn into_composer_status(self) -> CapabilityStatus; +} + +impl IntoComposerStatus for corvus_providers::CapabilityAvailability { + fn into_composer_status(self) -> CapabilityStatus { + match self { + corvus_providers::CapabilityAvailability::Constructible => { + CapabilityStatus::Constructible + } + corvus_providers::CapabilityAvailability::Uncompiled => CapabilityStatus::Uncompiled, + corvus_providers::CapabilityAvailability::PlatformUnavailable => { + CapabilityStatus::PlatformUnavailable + } + } + } +} +impl IntoComposerStatus for corvus_channels::CapabilityAvailability { + fn into_composer_status(self) -> CapabilityStatus { + match self { + corvus_channels::CapabilityAvailability::Constructible => { + CapabilityStatus::Constructible + } + corvus_channels::CapabilityAvailability::Uncompiled => CapabilityStatus::Uncompiled, + corvus_channels::CapabilityAvailability::PlatformUnavailable => { + CapabilityStatus::PlatformUnavailable + } + } + } +} +impl IntoComposerStatus for corvus_tools::CapabilityAvailability { + fn into_composer_status(self) -> CapabilityStatus { + match self { + corvus_tools::CapabilityAvailability::Constructible => CapabilityStatus::Constructible, + corvus_tools::CapabilityAvailability::Uncompiled => CapabilityStatus::Uncompiled, + corvus_tools::CapabilityAvailability::PlatformUnavailable => { + CapabilityStatus::PlatformUnavailable + } + } + } +} +impl IntoComposerStatus for corvus_memory::CapabilityAvailability { + fn into_composer_status(self) -> CapabilityStatus { + match self { + corvus_memory::CapabilityAvailability::Constructible => CapabilityStatus::Constructible, + corvus_memory::CapabilityAvailability::Uncompiled => CapabilityStatus::Uncompiled, + corvus_memory::CapabilityAvailability::PlatformUnavailable => { + CapabilityStatus::PlatformUnavailable + } + } + } +} +impl IntoComposerStatus for corvus_observability::CapabilityAvailability { + fn into_composer_status(self) -> CapabilityStatus { + match self { + corvus_observability::CapabilityAvailability::Constructible => { + CapabilityStatus::Constructible + } + corvus_observability::CapabilityAvailability::Uncompiled => { + CapabilityStatus::Uncompiled + } + corvus_observability::CapabilityAvailability::PlatformUnavailable => { + CapabilityStatus::PlatformUnavailable + } + } + } +} +impl IntoComposerStatus for corvus_security::CapabilityAvailability { + fn into_composer_status(self) -> CapabilityStatus { + match self { + corvus_security::CapabilityAvailability::Constructible => { + CapabilityStatus::Constructible + } + corvus_security::CapabilityAvailability::Uncompiled => CapabilityStatus::Uncompiled, + corvus_security::CapabilityAvailability::PlatformUnavailable => { + CapabilityStatus::PlatformUnavailable + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- CapabilityFamily::as_str --- + + #[test] + fn capability_family_as_str_provider() { + assert_eq!(CapabilityFamily::Provider.as_str(), "provider"); + } + + #[test] + fn capability_family_as_str_channel() { + assert_eq!(CapabilityFamily::Channel.as_str(), "channel"); + } + + #[test] + fn capability_family_as_str_tool() { + assert_eq!(CapabilityFamily::Tool.as_str(), "tool"); + } + + #[test] + fn capability_family_as_str_memory() { + assert_eq!(CapabilityFamily::Memory.as_str(), "memory"); + } + + #[test] + fn capability_family_as_str_observer() { + assert_eq!(CapabilityFamily::Observer.as_str(), "observer"); + } + + #[test] + fn capability_family_as_str_security() { + assert_eq!(CapabilityFamily::Security.as_str(), "security"); + } + + // --- RegistrySnapshot::collect --- + + #[test] + fn registry_snapshot_collect_is_non_empty() { + let snapshot = RegistrySnapshot::collect(); + assert!(!snapshot.records.is_empty()); + } + + #[test] + fn registry_snapshot_contains_records_from_all_families() { + let snapshot = RegistrySnapshot::collect(); + for family in &[ + CapabilityFamily::Provider, + CapabilityFamily::Channel, + CapabilityFamily::Tool, + CapabilityFamily::Memory, + CapabilityFamily::Observer, + CapabilityFamily::Security, + ] { + let has_family = snapshot.records.iter().any(|r| r.family == *family); + assert!( + has_family, + "snapshot is missing records for family '{}'", + family.as_str() + ); + } + } + + #[test] + fn registry_snapshot_contains_known_provider() { + let snapshot = RegistrySnapshot::collect(); + let record = snapshot.find_in_family(CapabilityFamily::Provider, "anthropic"); + assert!( + record.is_some(), + "expected 'anthropic' in Provider family" + ); + assert_eq!(record.unwrap().key, "anthropic"); + } + + #[test] + fn registry_snapshot_contains_known_channel() { + let snapshot = RegistrySnapshot::collect(); + let record = snapshot.find_in_family(CapabilityFamily::Channel, "stdio"); + assert!(record.is_some(), "expected 'stdio' in Channel family"); + assert_eq!(record.unwrap().key, "stdio"); + } + + #[test] + fn registry_snapshot_contains_known_memory_backend() { + let snapshot = RegistrySnapshot::collect(); + let record = snapshot.find_in_family(CapabilityFamily::Memory, "sqlite"); + assert!(record.is_some(), "expected 'sqlite' in Memory family"); + } + + // --- find_in_family --- + + #[test] + fn find_in_family_returns_none_for_unknown_key() { + let snapshot = RegistrySnapshot::collect(); + let result = snapshot.find_in_family(CapabilityFamily::Provider, "totally-unknown-xyz"); + assert!(result.is_none()); + } + + #[test] + fn find_in_family_is_case_insensitive() { + let snapshot = RegistrySnapshot::collect(); + let lower = snapshot.find_in_family(CapabilityFamily::Channel, "stdio"); + let upper = snapshot.find_in_family(CapabilityFamily::Channel, "STDIO"); + assert!(lower.is_some()); + assert!(upper.is_some()); + assert_eq!(lower.unwrap().key, upper.unwrap().key); + } + + #[test] + fn find_in_family_does_not_cross_family_boundary() { + let snapshot = RegistrySnapshot::collect(); + // "stdio" is a Channel, not a Provider + let result = snapshot.find_in_family(CapabilityFamily::Provider, "stdio"); + assert!( + result.is_none(), + "find_in_family must not cross family boundaries" + ); + } + + // --- find_in_other_family --- + + #[test] + fn find_in_other_family_finds_capability_in_different_family() { + let snapshot = RegistrySnapshot::collect(); + // "shell" is a Tool. Looking for it in Provider family should find the Tool record. + let result = snapshot.find_in_other_family(CapabilityFamily::Provider, "shell"); + assert!( + result.is_some(), + "expected to find 'shell' in a non-Provider family" + ); + assert_eq!(result.unwrap().family, CapabilityFamily::Tool); + } + + #[test] + fn find_in_other_family_returns_none_when_key_only_in_specified_family() { + let snapshot = RegistrySnapshot::collect(); + // "anthropic" is only a Provider. Looking for it in non-Provider families returns None. + let result = snapshot.find_in_other_family(CapabilityFamily::Provider, "anthropic"); + assert!( + result.is_none(), + "expected None since 'anthropic' is only in the Provider family" + ); + } + + #[test] + fn find_in_other_family_is_case_insensitive() { + let snapshot = RegistrySnapshot::collect(); + let lower = snapshot.find_in_other_family(CapabilityFamily::Provider, "shell"); + let upper = snapshot.find_in_other_family(CapabilityFamily::Provider, "SHELL"); + // Both should either both be Some or both be None. + assert_eq!(lower.is_some(), upper.is_some()); + } + + // --- CapabilityStatus --- + + #[test] + fn capability_status_variants_are_comparable() { + assert_eq!(CapabilityStatus::Constructible, CapabilityStatus::Constructible); + assert_ne!(CapabilityStatus::Constructible, CapabilityStatus::Uncompiled); + assert_ne!( + CapabilityStatus::Uncompiled, + CapabilityStatus::PlatformUnavailable + ); + } + + // Regression: every record in the snapshot has a non-empty key. + #[test] + fn all_snapshot_records_have_non_empty_key() { + let snapshot = RegistrySnapshot::collect(); + for record in &snapshot.records { + assert!( + !record.key.is_empty(), + "record with family '{}' has empty key", + record.family.as_str() + ); + } + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-composer/src/resolver.rs b/clients/agent-runtime/crates/corvus-composer/src/resolver.rs new file mode 100644 index 000000000..3bef3a627 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-composer/src/resolver.rs @@ -0,0 +1,862 @@ +use crate::manifest::AgentManifest; +use crate::plan::{ + AgentMetadata, CapabilityReport, ComposedRuntimePlan, IdentitySettings, MemorySettings, + RuntimeSettings, SelectedCapability, +}; +use crate::registry_snapshot::{CapabilityFamily, CapabilityStatus, RegistrySnapshot}; +use anyhow::{Context, Result}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + UnsupportedVersion { + version: String, + }, + MissingRequiredFamily { + family: &'static str, + }, + DefaultProviderDisabled { + name: String, + }, + DefaultChannelDisabled { + name: String, + }, + UnknownCapability { + family: &'static str, + name: String, + }, + FamilyMismatch { + expected_family: &'static str, + actual_family: &'static str, + name: String, + }, + UncompiledCapability { + family: &'static str, + name: String, + }, + PlatformUnavailableCapability { + family: &'static str, + name: String, + }, + InvalidCapabilityConfig { + family: &'static str, + name: String, + reason: String, + }, + ToolRestrictionsNotSubset { + restrictions: Vec, + enabled: Vec, + }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedVersion { version } => { + write!(f, "unsupported manifest version '{version}'") + } + Self::MissingRequiredFamily { family } => { + write!(f, "manifest must select at least one {family}") + } + Self::DefaultProviderDisabled { name } => { + write!(f, "default provider '{name}' must be enabled") + } + Self::DefaultChannelDisabled { name } => { + write!(f, "default channel '{name}' must be enabled") + } + Self::UnknownCapability { family, name } => { + write!(f, "unknown {family} capability '{name}'") + } + Self::FamilyMismatch { + expected_family, + actual_family, + name, + } => write!( + f, + "capability '{name}' belongs to family '{actual_family}', not '{expected_family}'" + ), + Self::UncompiledCapability { family, name } => write!( + f, + "{family} capability '{name}' is known but not compiled into this runtime artifact" + ), + Self::PlatformUnavailableCapability { family, name } => write!( + f, + "{family} capability '{name}' is unavailable on this platform" + ), + Self::InvalidCapabilityConfig { + family, + name, + reason, + } => write!(f, "invalid {family} configuration for '{name}': {reason}"), + Self::ToolRestrictionsNotSubset { + restrictions, + enabled, + } => write!( + f, + "tool restrictions {:?} must be subset of enabled tools {:?}", + restrictions, enabled + ), + } + } +} + +impl std::error::Error for ValidationError {} + +pub fn parse_manifest(toml_str: &str) -> Result { + toml::from_str(toml_str).context("failed to parse manifest TOML") +} + +pub fn resolve_manifest( + manifest: &AgentManifest, + snapshot: &RegistrySnapshot, +) -> Result { + validate_version(&manifest.version)?; + ensure_non_empty(CapabilityFamily::Provider, &manifest.providers.enabled)?; + ensure_non_empty(CapabilityFamily::Channel, &manifest.channels.enabled)?; + + // Canonicalize and validate default provider against registry + let _default_provider = snapshot + .find_in_family(CapabilityFamily::Provider, &manifest.providers.default) + .map(|r| r.key) + .ok_or_else(|| ValidationError::UnknownCapability { + family: CapabilityFamily::Provider.as_str(), + name: manifest.providers.default.clone(), + })?; + if !manifest + .providers + .enabled + .iter() + .any(|item| item.eq_ignore_ascii_case(&manifest.providers.default)) + { + return Err(ValidationError::DefaultProviderDisabled { + name: manifest.providers.default.clone(), + }); + } + + // Canonicalize and validate default channel against registry + if let Some(default_channel) = &manifest.channels.default { + let _canonical_channel = snapshot + .find_in_family(CapabilityFamily::Channel, default_channel) + .map(|r| r.key) + .ok_or_else(|| ValidationError::UnknownCapability { + family: CapabilityFamily::Channel.as_str(), + name: default_channel.clone(), + })?; + if !manifest + .channels + .enabled + .iter() + .any(|item| item.eq_ignore_ascii_case(default_channel)) + { + return Err(ValidationError::DefaultChannelDisabled { + name: default_channel.clone(), + }); + } + } + + validate_selected_config_keys( + CapabilityFamily::Provider, + &manifest.providers.enabled, + manifest.providers.config.keys().map(String::as_str), + )?; + validate_selected_config_keys( + CapabilityFamily::Channel, + &manifest.channels.enabled, + manifest.channels.config.keys().map(String::as_str), + )?; + validate_selected_config_keys( + CapabilityFamily::Tool, + &manifest.tools.enabled, + manifest.tools.config.keys().map(String::as_str), + )?; + validate_selected_config_keys( + CapabilityFamily::Memory, + std::slice::from_ref(&manifest.memory.backend), + manifest.memory.config.keys().map(String::as_str), + )?; + validate_selected_config_keys( + CapabilityFamily::Observer, + &manifest.observability.enabled, + manifest.observability.config.keys().map(String::as_str), + )?; + validate_selected_config_keys( + CapabilityFamily::Security, + std::slice::from_ref(&manifest.security.backend), + manifest.security.config.keys().map(String::as_str), + )?; + + let provider = resolve_one( + CapabilityFamily::Provider, + &manifest.providers.default, + snapshot, + manifest.providers.config.get(&manifest.providers.default), + )?; + let channels = resolve_many( + CapabilityFamily::Channel, + &manifest.channels.enabled, + snapshot, + &manifest.channels.config, + )?; + let tools = resolve_many( + CapabilityFamily::Tool, + &manifest.tools.enabled, + snapshot, + &manifest.tools.config, + )?; + let memory = resolve_one( + CapabilityFamily::Memory, + &manifest.memory.backend, + snapshot, + manifest.memory.config.get(&manifest.memory.backend), + )?; + let observers = resolve_many( + CapabilityFamily::Observer, + &manifest.observability.enabled, + snapshot, + &manifest.observability.config, + )?; + let security = resolve_one( + CapabilityFamily::Security, + &manifest.security.backend, + snapshot, + manifest.security.config.get(&manifest.security.backend), + )?; + + if !manifest.security.tool_restrictions.is_empty() { + let enabled_tools: Vec = tools.iter().map(|item| item.key.clone()).collect(); + if !manifest + .security + .tool_restrictions + .iter() + .all(|tool| enabled_tools.iter().any(|enabled| enabled == tool)) + { + return Err(ValidationError::ToolRestrictionsNotSubset { + restrictions: manifest.security.tool_restrictions.clone(), + enabled: enabled_tools, + }); + } + } + + Ok(ComposedRuntimePlan { + agent: AgentMetadata { + name: manifest.agent.name.clone(), + description: manifest.agent.description.clone(), + model: manifest.agent.model.clone(), + temperature: manifest.agent.temperature, + }, + provider, + channels, + default_channel: manifest + .channels + .default + .clone() + .or_else(|| manifest.channels.enabled.first().cloned()), + tools, + memory, + memory_settings: MemorySettings { + auto_save: manifest.memory.auto_save, + }, + observers, + security, + tool_restrictions: manifest.security.tool_restrictions.clone(), + runtime: RuntimeSettings { + profile: manifest + .runtime + .profile + .clone() + .or_else(|| manifest.agent.profile.clone()), + max_tool_iterations: manifest.runtime.max_tool_iterations, + }, + identity: IdentitySettings { + format: manifest.identity.format.clone(), + aieos_path: manifest.identity.aieos_path.clone(), + aieos_inline: manifest.identity.aieos_inline.clone(), + }, + }) +} + +pub fn required_capabilities(plan: &ComposedRuntimePlan) -> CapabilityReport { + CapabilityReport::from(plan) +} + +fn validate_version(version: &str) -> Result<(), ValidationError> { + let trimmed = version.trim(); + if trimmed == "1" || trimmed == "1.0" || trimmed.eq_ignore_ascii_case("v1") { + Ok(()) + } else { + Err(ValidationError::UnsupportedVersion { + version: version.to_string(), + }) + } +} + +fn ensure_non_empty(family: CapabilityFamily, items: &[String]) -> Result<(), ValidationError> { + if items.is_empty() { + Err(ValidationError::MissingRequiredFamily { + family: family.as_str(), + }) + } else { + Ok(()) + } +} + +fn validate_selected_config_keys<'a>( + family: CapabilityFamily, + selected: &[String], + configured_keys: impl Iterator, +) -> Result<(), ValidationError> { + for key in configured_keys { + if !selected.iter().any(|selected_key| selected_key == key) { + return Err(ValidationError::InvalidCapabilityConfig { + family: family.as_str(), + name: key.to_string(), + reason: "configuration exists for an unselected capability".to_string(), + }); + } + } + Ok(()) +} + +fn resolve_many( + family: CapabilityFamily, + requested: &[String], + snapshot: &RegistrySnapshot, + config: &std::collections::BTreeMap, +) -> Result, ValidationError> { + requested + .iter() + .map(|name| resolve_one(family, name, snapshot, config.get(name))) + .collect() +} + +fn resolve_one( + family: CapabilityFamily, + requested: &str, + snapshot: &RegistrySnapshot, + config: Option<&toml::Value>, +) -> Result { + let Some(record) = snapshot.find_in_family(family, requested) else { + if let Some(other_family) = snapshot.find_in_other_family(family, requested) { + return Err(ValidationError::FamilyMismatch { + expected_family: family.as_str(), + actual_family: other_family.family.as_str(), + name: requested.to_string(), + }); + } + return Err(ValidationError::UnknownCapability { + family: family.as_str(), + name: requested.to_string(), + }); + }; + + match record.status { + CapabilityStatus::Constructible => Ok(SelectedCapability { + key: record.key.to_string(), + config: config.cloned(), + }), + CapabilityStatus::Uncompiled => Err(ValidationError::UncompiledCapability { + family: family.as_str(), + name: record.key.to_string(), + }), + CapabilityStatus::PlatformUnavailable => { + Err(ValidationError::PlatformUnavailableCapability { + family: family.as_str(), + name: record.key.to_string(), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry_snapshot::RegistrySnapshot; + + // Helper: build a minimal manifest TOML string and return the parsed AgentManifest. + fn parse(toml_str: &str) -> crate::manifest::AgentManifest { + parse_manifest(toml_str).expect("test manifest must parse") + } + + fn snapshot() -> RegistrySnapshot { + RegistrySnapshot::collect() + } + + fn minimal_toml() -> &'static str { + r#" +version = "1" +[agent] +name = "minimal" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +"# + } + + // --- validate_version --- + + #[test] + fn validate_version_accepts_1() { + assert!(validate_version("1").is_ok()); + } + + #[test] + fn validate_version_accepts_1_dot_0() { + assert!(validate_version("1.0").is_ok()); + } + + #[test] + fn validate_version_accepts_v1_case_insensitive() { + assert!(validate_version("v1").is_ok()); + assert!(validate_version("V1").is_ok()); + } + + #[test] + fn validate_version_accepts_version_with_surrounding_whitespace() { + assert!(validate_version(" 1 ").is_ok()); + assert!(validate_version("\t1.0\n").is_ok()); + } + + #[test] + fn validate_version_rejects_unsupported_versions() { + for bad in &["2", "3.0", "0", "1.1", "v2", ""] { + let result = validate_version(bad); + assert!( + result.is_err(), + "expected error for version '{bad}', got Ok" + ); + match result.unwrap_err() { + ValidationError::UnsupportedVersion { version } => { + assert_eq!(version, *bad, "error should preserve original version string") + } + other => panic!("expected UnsupportedVersion, got {other:?}"), + } + } + } + + // --- ensure_non_empty --- + + #[test] + fn ensure_non_empty_passes_when_list_has_items() { + let items = vec!["a".to_string()]; + assert!(ensure_non_empty(CapabilityFamily::Provider, &items).is_ok()); + } + + #[test] + fn ensure_non_empty_fails_for_empty_list() { + let items: Vec = vec![]; + let result = ensure_non_empty(CapabilityFamily::Channel, &items); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ValidationError::MissingRequiredFamily { family: "channel" } + )); + } + + #[test] + fn ensure_non_empty_error_includes_family_name() { + let items: Vec = vec![]; + let err = ensure_non_empty(CapabilityFamily::Tool, &items).unwrap_err(); + assert_eq!(err.to_string(), "manifest must select at least one tool"); + } + + // --- validate_selected_config_keys --- + + #[test] + fn validate_selected_config_keys_passes_when_all_keys_are_selected() { + let selected = vec!["shell".to_string(), "file_read".to_string()]; + let configured_keys = ["shell", "file_read"]; + let result = validate_selected_config_keys( + CapabilityFamily::Tool, + &selected, + configured_keys.iter().copied(), + ); + assert!(result.is_ok()); + } + + #[test] + fn validate_selected_config_keys_passes_with_empty_config() { + let selected = vec!["shell".to_string()]; + let configured_keys: &[&str] = &[]; + let result = validate_selected_config_keys( + CapabilityFamily::Tool, + &selected, + configured_keys.iter().copied(), + ); + assert!(result.is_ok()); + } + + #[test] + fn validate_selected_config_keys_rejects_unselected_config_key() { + let selected = vec!["shell".to_string()]; + let configured_keys = ["browser"]; // not in selected list + let result = validate_selected_config_keys( + CapabilityFamily::Tool, + &selected, + configured_keys.iter().copied(), + ); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + ValidationError::InvalidCapabilityConfig { name, .. } if name == "browser" + )); + assert!(err + .to_string() + .contains("configuration exists for an unselected capability")); + } + + // --- ValidationError Display --- + + #[test] + fn validation_error_display_unsupported_version() { + let err = ValidationError::UnsupportedVersion { + version: "99".to_string(), + }; + assert_eq!(err.to_string(), "unsupported manifest version '99'"); + } + + #[test] + fn validation_error_display_missing_required_family() { + let err = ValidationError::MissingRequiredFamily { family: "provider" }; + assert_eq!( + err.to_string(), + "manifest must select at least one provider" + ); + } + + #[test] + fn validation_error_display_default_provider_disabled() { + let err = ValidationError::DefaultProviderDisabled { + name: "openai".to_string(), + }; + assert_eq!( + err.to_string(), + "default provider 'openai' must be enabled" + ); + } + + #[test] + fn validation_error_display_default_channel_disabled() { + let err = ValidationError::DefaultChannelDisabled { + name: "telegram".to_string(), + }; + assert_eq!( + err.to_string(), + "default channel 'telegram' must be enabled" + ); + } + + #[test] + fn validation_error_display_unknown_capability() { + let err = ValidationError::UnknownCapability { + family: "provider", + name: "unknown-thing".to_string(), + }; + assert_eq!( + err.to_string(), + "unknown provider capability 'unknown-thing'" + ); + } + + #[test] + fn validation_error_display_family_mismatch() { + let err = ValidationError::FamilyMismatch { + expected_family: "provider", + actual_family: "tool", + name: "shell".to_string(), + }; + assert!(err.to_string().contains("belongs to family 'tool'")); + assert!(err.to_string().contains("not 'provider'")); + } + + #[test] + fn validation_error_display_uncompiled_capability() { + let err = ValidationError::UncompiledCapability { + family: "channel", + name: "webhook".to_string(), + }; + assert!(err.to_string().contains("known but not compiled")); + } + + #[test] + fn validation_error_display_platform_unavailable() { + let err = ValidationError::PlatformUnavailableCapability { + family: "channel", + name: "imessage".to_string(), + }; + assert!(err.to_string().contains("unavailable on this platform")); + } + + #[test] + fn validation_error_display_tool_restrictions_not_subset() { + let err = ValidationError::ToolRestrictionsNotSubset { + restrictions: vec!["unknown_tool".to_string()], + enabled: vec!["shell".to_string()], + }; + assert!(err.to_string().contains("must be subset of enabled tools")); + } + + // --- resolve_manifest --- + + #[test] + fn resolve_manifest_succeeds_for_minimal_valid_manifest() { + let manifest = parse(minimal_toml()); + let snap = snapshot(); + let result = resolve_manifest(&manifest, &snap); + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + fn resolve_manifest_populates_agent_metadata() { + let manifest = parse(minimal_toml()); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + assert_eq!(plan.agent.name, "minimal"); + } + + #[test] + fn resolve_manifest_default_channel_falls_back_to_first_enabled() { + // [channels] has no 'default' field; should fall back to first enabled. + let manifest = parse(minimal_toml()); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + assert_eq!(plan.default_channel.as_deref(), Some("stdio")); + } + + #[test] + fn resolve_manifest_explicit_default_channel_is_preserved() { + let toml_str = r#" +version = "1" +[agent] +name = "multi-chan" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio", "telegram"] +default = "telegram" +[memory] +backend = "none" +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + assert_eq!(plan.default_channel.as_deref(), Some("telegram")); + } + + #[test] + fn resolve_manifest_runtime_profile_falls_back_to_agent_profile() { + let toml_str = r#" +version = "1" +[agent] +name = "profile-agent" +profile = "code" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + // runtime.profile should fall back to agent.profile when not explicitly set + assert_eq!(plan.runtime.profile.as_deref(), Some("code")); + } + + #[test] + fn resolve_manifest_runtime_profile_section_overrides_agent_profile() { + let toml_str = r#" +version = "1" +[agent] +name = "profile-override" +profile = "lite" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +[runtime] +profile = "full" +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + assert_eq!(plan.runtime.profile.as_deref(), Some("full")); + } + + #[test] + fn resolve_manifest_tool_restrictions_must_be_subset_of_enabled_tools() { + let toml_str = r#" +version = "1" +[agent] +name = "restricted" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +[tools] +enabled = ["shell"] +[security] +backend = "none" +tool_restrictions = ["shell", "file_read"] +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let result = resolve_manifest(&manifest, &snap); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ValidationError::ToolRestrictionsNotSubset { .. } + )); + } + + #[test] + fn resolve_manifest_valid_tool_restrictions_subset_passes() { + let toml_str = r#" +version = "1" +[agent] +name = "ok-restricted" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +[tools] +enabled = ["shell", "file_read"] +[security] +backend = "none" +tool_restrictions = ["shell"] +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let result = resolve_manifest(&manifest, &snap); + assert!(result.is_ok()); + let plan = result.unwrap(); + assert_eq!(plan.tool_restrictions, vec!["shell"]); + } + + #[test] + fn resolve_manifest_default_channel_not_in_enabled_fails() { + let toml_str = r#" +version = "1" +[agent] +name = "bad-default" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +default = "telegram" +[memory] +backend = "none" +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let result = resolve_manifest(&manifest, &snap); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ValidationError::DefaultChannelDisabled { name } if name == "telegram" + )); + } + + #[test] + fn resolve_manifest_memory_settings_auto_save_propagated() { + let toml_str = r#" +version = "1" +[agent] +name = "memory-agent" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "sqlite" +auto_save = true +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + assert_eq!(plan.memory_settings.auto_save, Some(true)); + } + + #[test] + fn resolve_manifest_identity_fields_propagated() { + let toml_str = r#" +version = "1" +[agent] +name = "identity-agent" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +[identity] +format = "openclaw" +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let plan = resolve_manifest(&manifest, &snap).unwrap(); + assert_eq!(plan.identity.format.as_deref(), Some("openclaw")); + } + + // Regression: unsupported version in manifest must be caught before registry lookup. + #[test] + fn resolve_manifest_rejects_unsupported_manifest_version() { + let toml_str = r#" +version = "99" +[agent] +name = "future" +[providers] +enabled = ["anthropic"] +default = "anthropic" +[channels] +enabled = ["stdio"] +[memory] +backend = "none" +"#; + let manifest = parse(toml_str); + let snap = snapshot(); + let result = resolve_manifest(&manifest, &snap); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ValidationError::UnsupportedVersion { .. } + )); + } + + // --- parse_manifest --- + + #[test] + fn parse_manifest_returns_error_for_invalid_toml() { + let result = parse_manifest("this is not valid toml !!!"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("failed to parse manifest TOML")); + } + + #[test] + fn parse_manifest_returns_error_for_missing_required_fields() { + // Missing [providers], [channels], [memory] + let result = parse_manifest("version = \"1\"\n[agent]\nname = \"x\"\n"); + assert!(result.is_err()); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-memory/src/factory.rs b/clients/agent-runtime/crates/corvus-memory/src/factory.rs new file mode 100644 index 000000000..31f2d20a6 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-memory/src/factory.rs @@ -0,0 +1,94 @@ +use anyhow::{anyhow, Result}; + +use crate::registry::{memory_availability, resolve_memory_backend_key, CapabilityAvailability}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemoryFactorySelection { + pub key: &'static str, +} + +pub fn select_memory_backend(name: &str) -> Result { + let Some(key) = resolve_memory_backend_key(name) else { + return Err(anyhow!("unknown memory backend '{name}'")); + }; + + match memory_availability(key) { + Some(CapabilityAvailability::Constructible) => Ok(MemoryFactorySelection { key }), + Some(CapabilityAvailability::Uncompiled) => { + Err(anyhow!("memory backend '{key}' is known but not compiled")) + } + Some(CapabilityAvailability::PlatformUnavailable) => Err(anyhow!( + "memory backend '{key}' is unavailable on this platform" + )), + None => Err(anyhow!("unknown memory backend '{name}'")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_memory_backend_returns_ok_for_sqlite() { + let result = select_memory_backend("sqlite"); + assert!(result.is_ok(), "expected Ok for 'sqlite', got: {result:?}"); + assert_eq!(result.unwrap().key, "sqlite"); + } + + #[test] + fn select_memory_backend_returns_ok_for_all_shipped_backends() { + for name in &["sqlite", "lucid", "markdown", "none"] { + let result = select_memory_backend(name); + assert!( + result.is_ok(), + "expected Ok for '{name}', got: {result:?}" + ); + } + } + + #[test] + fn select_memory_backend_key_is_canonical_lowercase() { + let selection = select_memory_backend("SQLITE").unwrap(); + assert_eq!(selection.key, "sqlite"); + } + + #[test] + fn select_memory_backend_err_for_unknown_name() { + let result = select_memory_backend("totally-unknown"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("unknown memory backend"), + "error message was: {msg}" + ); + } + + #[test] + fn select_memory_backend_err_for_empty_string() { + let result = select_memory_backend(""); + assert!(result.is_err()); + } + + #[test] + fn memory_factory_selection_is_copy() { + let sel = MemoryFactorySelection { key: "sqlite" }; + let copy = sel; + assert_eq!(sel, copy); + } + + // Regression: selecting the same backend twice yields identical results. + #[test] + fn select_memory_backend_is_idempotent() { + let first = select_memory_backend("markdown").unwrap(); + let second = select_memory_backend("markdown").unwrap(); + assert_eq!(first, second); + } + + // Boundary: whitespace-padded name should resolve through trimming. + #[test] + fn select_memory_backend_accepts_padded_name() { + let result = select_memory_backend(" none "); + assert!(result.is_ok()); + assert_eq!(result.unwrap().key, "none"); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-memory/src/lib.rs b/clients/agent-runtime/crates/corvus-memory/src/lib.rs index fe4ee84c1..91e156c5a 100644 --- a/clients/agent-runtime/crates/corvus-memory/src/lib.rs +++ b/clients/agent-runtime/crates/corvus-memory/src/lib.rs @@ -1,14 +1,13 @@ -//! Corvus Memory Registry -//! -//! Re-exports memory types and provides registry functions. +//! Corvus memory registry surfaces for manifest composition. + +pub mod factory; +pub mod registry; pub use corvus_traits::memory::{ Memory, MemoryCategory, MemoryEntry, MemoryStats, SessionEntry, SessionStatus, }; - -/// Information about a memory backend. -#[derive(Debug, Clone)] -pub struct MemoryInfo { - pub name: &'static str, - pub display_name: &'static str, -} +pub use factory::{select_memory_backend, MemoryFactorySelection}; +pub use registry::{ + list_memory_backends, memory_availability, resolve_memory_backend_key, CapabilityAvailability, + MemoryDescriptor, +}; diff --git a/clients/agent-runtime/crates/corvus-memory/src/registry.rs b/clients/agent-runtime/crates/corvus-memory/src/registry.rs new file mode 100644 index 000000000..1ca5f3906 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-memory/src/registry.rs @@ -0,0 +1,225 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAvailability { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemoryDescriptor { + pub key: &'static str, + pub display_name: &'static str, + pub aliases: &'static [&'static str], + pub compiled: bool, + pub platform_supported: bool, +} + +const MEMORY_BACKENDS: &[MemoryDescriptor] = &[ + MemoryDescriptor { + key: "sqlite", + display_name: "SQLite", + aliases: &[], + compiled: true, + platform_supported: true, + }, + MemoryDescriptor { + key: "lucid", + display_name: "Lucid", + aliases: &[], + compiled: true, + platform_supported: true, + }, + MemoryDescriptor { + key: "markdown", + display_name: "Markdown", + aliases: &[], + compiled: true, + platform_supported: true, + }, + MemoryDescriptor { + key: "none", + display_name: "None", + aliases: &[], + compiled: true, + platform_supported: true, + }, +]; + +pub fn list_memory_backends() -> &'static [MemoryDescriptor] { + MEMORY_BACKENDS +} + +pub fn resolve_memory_backend_key(name: &str) -> Option<&'static str> { + let candidate = name.trim(); + MEMORY_BACKENDS + .iter() + .find(|descriptor| { + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) + .map(|descriptor| descriptor.key) +} + +pub fn memory_availability(name: &str) -> Option { + let key = resolve_memory_backend_key(name)?; + MEMORY_BACKENDS + .iter() + .find(|descriptor| descriptor.key == key) + .map(|descriptor| { + if !descriptor.platform_supported { + CapabilityAvailability::PlatformUnavailable + } else if !descriptor.compiled { + CapabilityAvailability::Uncompiled + } else { + CapabilityAvailability::Constructible + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- list_memory_backends --- + + #[test] + fn list_memory_backends_is_non_empty() { + assert!(!list_memory_backends().is_empty()); + } + + #[test] + fn list_memory_backends_contains_exactly_four_backends() { + assert_eq!(list_memory_backends().len(), 4); + } + + #[test] + fn list_memory_backends_includes_all_expected_keys() { + let backends = list_memory_backends(); + let keys: Vec<&str> = backends.iter().map(|b| b.key).collect(); + for expected in &["sqlite", "lucid", "markdown", "none"] { + assert!( + keys.contains(expected), + "expected memory backend '{expected}' not found in registry" + ); + } + } + + #[test] + fn list_memory_backends_has_unique_keys() { + let mut seen = std::collections::HashSet::new(); + for descriptor in list_memory_backends() { + assert!( + seen.insert(descriptor.key), + "duplicate memory backend key: '{}'", + descriptor.key + ); + } + } + + #[test] + fn all_memory_descriptors_have_non_empty_display_name() { + for descriptor in list_memory_backends() { + assert!( + !descriptor.display_name.is_empty(), + "memory backend '{}' has empty display_name", + descriptor.key + ); + } + } + + // --- resolve_memory_backend_key --- + + #[test] + fn resolve_memory_backend_key_returns_none_for_unknown() { + assert_eq!(resolve_memory_backend_key("not-a-backend"), None); + assert_eq!(resolve_memory_backend_key(""), None); + } + + #[test] + fn resolve_memory_backend_key_matches_all_known_backends() { + for key in &["sqlite", "lucid", "markdown", "none"] { + assert_eq!( + resolve_memory_backend_key(key), + Some(*key), + "failed for key '{key}'" + ); + } + } + + #[test] + fn resolve_memory_backend_key_is_case_insensitive() { + assert_eq!(resolve_memory_backend_key("SQLITE"), Some("sqlite")); + assert_eq!(resolve_memory_backend_key("Markdown"), Some("markdown")); + assert_eq!(resolve_memory_backend_key("NONE"), Some("none")); + assert_eq!(resolve_memory_backend_key("LUCID"), Some("lucid")); + } + + #[test] + fn resolve_memory_backend_key_trims_whitespace() { + assert_eq!(resolve_memory_backend_key(" sqlite "), Some("sqlite")); + assert_eq!(resolve_memory_backend_key("\tnone\n"), Some("none")); + } + + #[test] + fn resolve_memory_backend_key_returns_canonical_lowercase_key() { + let key = resolve_memory_backend_key("MARKDOWN").unwrap(); + assert_eq!(key, "markdown"); + } + + // Regression: whitespace-only must not match anything. + #[test] + fn resolve_memory_backend_key_whitespace_only_returns_none() { + assert_eq!(resolve_memory_backend_key(" "), None); + } + + // --- memory_availability --- + + #[test] + fn memory_availability_returns_none_for_unknown() { + assert_eq!(memory_availability("unknown-backend"), None); + } + + #[test] + fn memory_availability_constructible_for_all_shipped_backends() { + for name in &["sqlite", "lucid", "markdown", "none"] { + assert_eq!( + memory_availability(name), + Some(CapabilityAvailability::Constructible), + "expected Constructible for '{name}'" + ); + } + } + + #[test] + fn memory_availability_is_case_insensitive() { + assert_eq!( + memory_availability("SQLITE"), + Some(CapabilityAvailability::Constructible) + ); + assert_eq!( + memory_availability("None"), + Some(CapabilityAvailability::Constructible) + ); + } + + // --- CapabilityAvailability enum --- + + #[test] + fn capability_availability_variants_are_eq_comparable() { + assert_eq!( + CapabilityAvailability::Constructible, + CapabilityAvailability::Constructible + ); + assert_ne!( + CapabilityAvailability::Constructible, + CapabilityAvailability::Uncompiled + ); + assert_ne!( + CapabilityAvailability::Uncompiled, + CapabilityAvailability::PlatformUnavailable + ); + } +} \ No newline at end of file diff --git a/clients/agent-runtime/crates/corvus-observability/Cargo.toml b/clients/agent-runtime/crates/corvus-observability/Cargo.toml new file mode 100644 index 000000000..313cb7ca4 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-observability/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "corvus-observability" +version = "0.1.0" +edition = "2021" +authors = ["theonlyhennygod"] +license = "Apache-2.0" +description = "Observability registry for Corvus agent runtime" +repository = "https://github.com/dallay/corvus" + +[dependencies] +anyhow = "1.0" + +[package.metadata.docs.rs] +all-features = true diff --git a/clients/agent-runtime/crates/corvus-observability/src/lib.rs b/clients/agent-runtime/crates/corvus-observability/src/lib.rs new file mode 100644 index 000000000..efca850e6 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-observability/src/lib.rs @@ -0,0 +1,158 @@ +use anyhow::{anyhow, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAvailability { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObserverDescriptor { + pub key: &'static str, + pub display_name: &'static str, + pub aliases: &'static [&'static str], + pub compiled: bool, + pub platform_supported: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObserverFactorySelection { + pub key: &'static str, +} + +const OBSERVERS: &[ObserverDescriptor] = &[ + ObserverDescriptor { + key: "none", + display_name: "Noop", + aliases: &["noop"], + compiled: true, + platform_supported: true, + }, + ObserverDescriptor { + key: "log", + display_name: "Log", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ObserverDescriptor { + key: "prometheus", + display_name: "Prometheus", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ObserverDescriptor { + key: "otel", + display_name: "OpenTelemetry", + aliases: &["opentelemetry", "otlp"], + compiled: true, + platform_supported: true, + }, +]; + +pub fn list_observers() -> &'static [ObserverDescriptor] { + OBSERVERS +} + +pub fn resolve_observer_key(name: &str) -> Option<&'static str> { + let candidate = name.trim(); + OBSERVERS + .iter() + .find(|descriptor| { + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) + .map(|descriptor| descriptor.key) +} + +pub fn observer_availability(name: &str) -> Option { + let key = resolve_observer_key(name)?; + OBSERVERS + .iter() + .find(|descriptor| descriptor.key == key) + .map(|descriptor| { + if !descriptor.platform_supported { + CapabilityAvailability::PlatformUnavailable + } else if !descriptor.compiled { + CapabilityAvailability::Uncompiled + } else { + CapabilityAvailability::Constructible + } + }) +} + +pub fn select_observer(name: &str) -> Result { + let Some(descriptor) = OBSERVERS.iter().find(|descriptor| { + let candidate = name.trim(); + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) else { + return Err(anyhow!("unknown observer '{name}'")); + }; + + let key = descriptor.key; + if !descriptor.platform_supported { + return Err(anyhow!("observer '{key}' is unavailable on this platform")); + } + if !descriptor.compiled { + return Err(anyhow!("observer '{key}' is known but not compiled")); + } + + Ok(ObserverFactorySelection { key }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_observer_aliases() { + assert_eq!(resolve_observer_key("noop"), Some("none")); + assert_eq!(resolve_observer_key("otlp"), Some("otel")); + } + + #[test] + fn resolve_observer_key_trims_whitespace() { + assert_eq!(resolve_observer_key(" none "), Some("none")); + assert_eq!(resolve_observer_key("\totel\t"), Some("otel")); + } + + #[test] + fn resolve_observer_key_is_case_insensitive() { + assert_eq!(resolve_observer_key("NONE"), Some("none")); + assert_eq!(resolve_observer_key("OTEL"), Some("otel")); + assert_eq!(resolve_observer_key("NoOp"), Some("none")); + assert_eq!(resolve_observer_key("Otlp"), Some("otel")); + } + + #[test] + fn select_observer_returns_selection_for_valid_keys() { + assert_eq!(select_observer("none").unwrap().key, "none"); + assert_eq!(select_observer("noop").unwrap().key, "none"); + assert_eq!(select_observer("otel").unwrap().key, "otel"); + assert_eq!(select_observer("otlp").unwrap().key, "otel"); + } + + #[test] + fn select_observer_errors_on_unknown_input() { + let result = select_observer("unknown"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("unknown observer")); + assert!(msg.contains("unknown")); + } + + #[test] + fn select_observer_errors_on_unknown_after_trimming() { + let result = select_observer(" "); + assert!(result.is_err()); + } +} diff --git a/clients/agent-runtime/crates/corvus-providers/src/factory.rs b/clients/agent-runtime/crates/corvus-providers/src/factory.rs new file mode 100644 index 000000000..c6f6631c2 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-providers/src/factory.rs @@ -0,0 +1,25 @@ +use anyhow::{anyhow, Result}; + +use crate::registry::{provider_availability, resolve_provider_key, CapabilityAvailability}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderFactorySelection { + pub key: &'static str, +} + +pub fn select_provider(name: &str) -> Result { + let Some(key) = resolve_provider_key(name) else { + return Err(anyhow!("unknown provider '{name}'")); + }; + + match provider_availability(key) { + Some(CapabilityAvailability::Constructible) => Ok(ProviderFactorySelection { key }), + Some(CapabilityAvailability::Uncompiled) => { + Err(anyhow!("provider '{key}' is known but not compiled")) + } + Some(CapabilityAvailability::PlatformUnavailable) => { + Err(anyhow!("provider '{key}' is unavailable on this platform")) + } + None => Err(anyhow!("unknown provider '{name}'")), + } +} diff --git a/clients/agent-runtime/crates/corvus-providers/src/lib.rs b/clients/agent-runtime/crates/corvus-providers/src/lib.rs index 3d4a8782e..95ea8c89e 100644 --- a/clients/agent-runtime/crates/corvus-providers/src/lib.rs +++ b/clients/agent-runtime/crates/corvus-providers/src/lib.rs @@ -1,19 +1,14 @@ -//! Corvus Providers Registry -//! -//! Re-exports provider types and provides registry functions. +//! Corvus provider registry surfaces for manifest composition. -pub use corvus_traits::providers::Provider; +pub mod factory; +pub mod registry; -/// Information about a provider. -#[derive(Debug, Clone)] -pub struct ProviderInfo { - pub name: &'static str, - pub display_name: &'static str, - pub local: bool, -} - -// Re-export types pub use corvus_traits::providers::{ - ChatMessage, ChatRequest, ChatResponse, ConversationMessage, StreamChunk, StreamOptions, - ToolCall, ToolResultMessage, + ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, StreamChunk, + StreamOptions, ToolCall, ToolResultMessage, +}; +pub use factory::{select_provider, ProviderFactorySelection}; +pub use registry::{ + list_providers, provider_availability, resolve_provider_key, CapabilityAvailability, + ProviderDescriptor, }; diff --git a/clients/agent-runtime/crates/corvus-providers/src/registry.rs b/clients/agent-runtime/crates/corvus-providers/src/registry.rs new file mode 100644 index 000000000..daec6679f --- /dev/null +++ b/clients/agent-runtime/crates/corvus-providers/src/registry.rs @@ -0,0 +1,337 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAvailability { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderDescriptor { + pub key: &'static str, + pub display_name: &'static str, + pub aliases: &'static [&'static str], + pub compiled: bool, + pub platform_supported: bool, + pub supports_native_tools: bool, +} + +const PROVIDERS: &[ProviderDescriptor] = &[ + ProviderDescriptor { + key: "openrouter", + display_name: "OpenRouter", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "anthropic", + display_name: "Anthropic", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: true, + }, + ProviderDescriptor { + key: "openai", + display_name: "OpenAI", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: true, + }, + ProviderDescriptor { + key: "openai-codex", + display_name: "OpenAI Codex", + aliases: &["openai_codex", "codex"], + compiled: true, + platform_supported: true, + supports_native_tools: true, + }, + ProviderDescriptor { + key: "ollama", + display_name: "Ollama", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "gemini", + display_name: "Google Gemini", + aliases: &["google", "google-gemini"], + compiled: true, + platform_supported: true, + supports_native_tools: true, + }, + ProviderDescriptor { + key: "venice", + display_name: "Venice", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "vercel", + display_name: "Vercel AI Gateway", + aliases: &["vercel-ai"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "cloudflare", + display_name: "Cloudflare AI", + aliases: &["cloudflare-ai"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "moonshot", + display_name: "Moonshot", + aliases: &[ + "moonshot-intl", + "moonshot-global", + "kimi", + "kimi-intl", + "kimi-global", + "moonshot-cn", + "kimi-cn", + ], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "synthetic", + display_name: "Synthetic", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "opencode", + display_name: "OpenCode Zen", + aliases: &["opencode-zen"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "zai", + display_name: "Z.AI", + aliases: &["z.ai", "zai-global", "z.ai-global", "zai-cn", "z.ai-cn"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "glm", + display_name: "GLM", + aliases: &[ + "zhipu", + "glm-global", + "zhipu-global", + "glm-cn", + "zhipu-cn", + "bigmodel", + ], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "minimax", + display_name: "MiniMax", + aliases: &[ + "minimax-intl", + "minimax-io", + "minimax-global", + "minimax-cn", + "minimaxi", + ], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "bedrock", + display_name: "Amazon Bedrock", + aliases: &["aws-bedrock"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "qianfan", + display_name: "Qianfan", + aliases: &["baidu"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "qwen", + display_name: "Qwen", + aliases: &[ + "dashscope", + "qwen-cn", + "dashscope-cn", + "qwen-intl", + "dashscope-intl", + "qwen-international", + "dashscope-international", + "qwen-us", + "dashscope-us", + ], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "groq", + display_name: "Groq", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "mistral", + display_name: "Mistral", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "xai", + display_name: "xAI", + aliases: &["grok"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "deepseek", + display_name: "DeepSeek", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "together", + display_name: "Together AI", + aliases: &["together-ai"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "fireworks", + display_name: "Fireworks AI", + aliases: &["fireworks-ai"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "perplexity", + display_name: "Perplexity", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "cohere", + display_name: "Cohere", + aliases: &[], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "copilot", + display_name: "GitHub Copilot", + aliases: &["github-copilot"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "lmstudio", + display_name: "LM Studio", + aliases: &["lm-studio"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, + ProviderDescriptor { + key: "nvidia", + display_name: "NVIDIA NIM", + aliases: &["nvidia-nim", "build.nvidia.com"], + compiled: true, + platform_supported: true, + supports_native_tools: false, + }, +]; + +pub fn list_providers() -> &'static [ProviderDescriptor] { + PROVIDERS +} + +pub fn resolve_provider_key(name: &str) -> Option<&'static str> { + let candidate = name.trim(); + PROVIDERS + .iter() + .find(|descriptor| { + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) + .map(|descriptor| descriptor.key) +} + +pub fn provider_availability(name: &str) -> Option { + let key = resolve_provider_key(name)?; + PROVIDERS + .iter() + .find(|descriptor| descriptor.key == key) + .map(|descriptor| { + if !descriptor.platform_supported { + CapabilityAvailability::PlatformUnavailable + } else if !descriptor.compiled { + CapabilityAvailability::Uncompiled + } else { + CapabilityAvailability::Constructible + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_aliases_to_canonical_provider_keys() { + // Primary aliases + assert_eq!(resolve_provider_key("google"), Some("gemini")); + assert_eq!(resolve_provider_key("github-copilot"), Some("copilot")); + + // Regional aliases for Moonshot + assert_eq!(resolve_provider_key("kimi"), Some("moonshot")); + assert_eq!(resolve_provider_key("kimi-intl"), Some("moonshot")); + assert_eq!(resolve_provider_key("kimi-global"), Some("moonshot")); + assert_eq!(resolve_provider_key("kimi-cn"), Some("moonshot")); + assert_eq!(resolve_provider_key("moonshot-intl"), Some("moonshot")); + assert_eq!(resolve_provider_key("moonshot-global"), Some("moonshot")); + assert_eq!(resolve_provider_key("moonshot-cn"), Some("moonshot")); + } +} diff --git a/clients/agent-runtime/crates/corvus-security/src/factory.rs b/clients/agent-runtime/crates/corvus-security/src/factory.rs new file mode 100644 index 000000000..c74113e0f --- /dev/null +++ b/clients/agent-runtime/crates/corvus-security/src/factory.rs @@ -0,0 +1,25 @@ +use anyhow::{anyhow, Result}; + +use crate::registry::{resolve_sandbox_key, sandbox_availability, CapabilityAvailability}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SandboxFactorySelection { + pub key: &'static str, +} + +pub fn select_sandbox(name: &str) -> Result { + let Some(key) = resolve_sandbox_key(name) else { + return Err(anyhow!("unknown sandbox '{name}'")); + }; + + match sandbox_availability(key) { + Some(CapabilityAvailability::Constructible) => Ok(SandboxFactorySelection { key }), + Some(CapabilityAvailability::Uncompiled) => { + Err(anyhow!("sandbox '{key}' is known but not compiled")) + } + Some(CapabilityAvailability::PlatformUnavailable) => { + Err(anyhow!("sandbox '{key}' is unavailable on this platform")) + } + None => Err(anyhow!("unknown sandbox '{name}'")), + } +} diff --git a/clients/agent-runtime/crates/corvus-security/src/lib.rs b/clients/agent-runtime/crates/corvus-security/src/lib.rs index 572aac97f..df8870f9f 100644 --- a/clients/agent-runtime/crates/corvus-security/src/lib.rs +++ b/clients/agent-runtime/crates/corvus-security/src/lib.rs @@ -1,13 +1,11 @@ -//! Corvus Security Registry -//! -//! Re-exports security types and provides registry functions. +//! Corvus security registry surfaces for manifest composition. -pub use corvus_traits::security::{NoopSandbox, Sandbox}; +pub mod factory; +pub mod registry; -/// Information about a security implementation. -#[derive(Debug, Clone)] -pub struct SecurityInfo { - pub name: &'static str, - pub display_name: &'static str, - pub is_sandbox: bool, -} +pub use corvus_traits::security::{NoopSandbox, Sandbox}; +pub use factory::{select_sandbox, SandboxFactorySelection}; +pub use registry::{ + list_sandboxes, resolve_sandbox_key, sandbox_availability, CapabilityAvailability, + SandboxDescriptor, +}; diff --git a/clients/agent-runtime/crates/corvus-security/src/registry.rs b/clients/agent-runtime/crates/corvus-security/src/registry.rs new file mode 100644 index 000000000..bcad283cf --- /dev/null +++ b/clients/agent-runtime/crates/corvus-security/src/registry.rs @@ -0,0 +1,94 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAvailability { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SandboxDescriptor { + pub key: &'static str, + pub display_name: &'static str, + pub aliases: &'static [&'static str], + pub compiled: bool, + pub platform_supported: bool, +} + +const SANDBOXES: &[SandboxDescriptor] = &[ + SandboxDescriptor { + key: "auto", + display_name: "Auto Detect", + aliases: &[], + compiled: true, + platform_supported: true, + }, + SandboxDescriptor { + key: "none", + display_name: "No Sandbox", + aliases: &["noop"], + compiled: true, + platform_supported: true, + }, + SandboxDescriptor { + key: "docker", + display_name: "Docker", + aliases: &[], + compiled: true, + platform_supported: true, + }, + SandboxDescriptor { + key: "firejail", + display_name: "Firejail", + aliases: &[], + compiled: cfg!(target_os = "linux"), + platform_supported: cfg!(target_os = "linux"), + }, + SandboxDescriptor { + key: "landlock", + display_name: "Landlock", + aliases: &[], + compiled: false, + platform_supported: cfg!(target_os = "linux"), + }, + SandboxDescriptor { + key: "bubblewrap", + display_name: "Bubblewrap", + aliases: &[], + compiled: false, + platform_supported: cfg!(any(target_os = "linux", target_os = "macos")), + }, +]; + +pub fn list_sandboxes() -> &'static [SandboxDescriptor] { + SANDBOXES +} + +pub fn resolve_sandbox_key(name: &str) -> Option<&'static str> { + let candidate = name.trim(); + SANDBOXES + .iter() + .find(|descriptor| { + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) + .map(|descriptor| descriptor.key) +} + +pub fn sandbox_availability(name: &str) -> Option { + let key = resolve_sandbox_key(name)?; + SANDBOXES + .iter() + .find(|descriptor| descriptor.key == key) + .map(|descriptor| { + if !descriptor.platform_supported { + CapabilityAvailability::PlatformUnavailable + } else if !descriptor.compiled { + CapabilityAvailability::Uncompiled + } else { + CapabilityAvailability::Constructible + } + }) +} diff --git a/clients/agent-runtime/crates/corvus-tools/src/factory.rs b/clients/agent-runtime/crates/corvus-tools/src/factory.rs new file mode 100644 index 000000000..0e9624555 --- /dev/null +++ b/clients/agent-runtime/crates/corvus-tools/src/factory.rs @@ -0,0 +1,25 @@ +use anyhow::{anyhow, Result}; + +use crate::registry::{resolve_tool_key, tool_availability, CapabilityAvailability}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolFactorySelection { + pub key: &'static str, +} + +pub fn select_tool(name: &str) -> Result { + let Some(key) = resolve_tool_key(name) else { + return Err(anyhow!("unknown tool '{name}'")); + }; + + match tool_availability(key) { + Some(CapabilityAvailability::Constructible) => Ok(ToolFactorySelection { key }), + Some(CapabilityAvailability::Uncompiled) => { + Err(anyhow!("tool '{key}' is known but not compiled")) + } + Some(CapabilityAvailability::PlatformUnavailable) => { + Err(anyhow!("tool '{key}' is unavailable on this platform")) + } + None => Err(anyhow!("unknown tool '{name}'")), + } +} diff --git a/clients/agent-runtime/crates/corvus-tools/src/lib.rs b/clients/agent-runtime/crates/corvus-tools/src/lib.rs index d57071a68..bf125bba6 100644 --- a/clients/agent-runtime/crates/corvus-tools/src/lib.rs +++ b/clients/agent-runtime/crates/corvus-tools/src/lib.rs @@ -1,12 +1,10 @@ -//! Corvus Tools Registry -//! -//! Re-exports tool types and provides registry functions. +//! Corvus tool registry surfaces for manifest composition. -pub use corvus_traits::tools::{Tool, ToolResult, ToolSpec}; +pub mod factory; +pub mod registry; -/// Information about a tool. -#[derive(Debug, Clone)] -pub struct ToolInfo { - pub name: &'static str, - pub display_name: &'static str, -} +pub use corvus_traits::tools::{Tool, ToolResult, ToolSpec}; +pub use factory::{select_tool, ToolFactorySelection}; +pub use registry::{ + list_tools, resolve_tool_key, tool_availability, CapabilityAvailability, ToolDescriptor, +}; diff --git a/clients/agent-runtime/crates/corvus-tools/src/registry.rs b/clients/agent-runtime/crates/corvus-tools/src/registry.rs new file mode 100644 index 000000000..9d1b41fcd --- /dev/null +++ b/clients/agent-runtime/crates/corvus-tools/src/registry.rs @@ -0,0 +1,289 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAvailability { + Constructible, + Uncompiled, + PlatformUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolDescriptor { + pub key: &'static str, + pub display_name: &'static str, + pub aliases: &'static [&'static str], + pub compiled: bool, + pub platform_supported: bool, +} + +const TOOLS: &[ToolDescriptor] = &[ + ToolDescriptor { + key: "shell", + display_name: "Shell", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "code_search", + display_name: "Code Search", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "file_read", + display_name: "File Read", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "file_write", + display_name: "File Write", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "cron_add", + display_name: "Cron Add", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "cron_list", + display_name: "Cron List", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "cron_remove", + display_name: "Cron Remove", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "cron_update", + display_name: "Cron Update", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "cron_run", + display_name: "Cron Run", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "cron_runs", + display_name: "Cron Runs", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "schedule", + display_name: "Schedule", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "git_operations", + display_name: "Git Operations", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "pushover", + display_name: "Pushover", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "memory_store", + display_name: "Memory Store", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "memory_recall", + display_name: "Memory Recall", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "memory_forget", + display_name: "Memory Forget", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "browser_open", + display_name: "Browser Open", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "browser", + display_name: "Browser", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "http_request", + display_name: "HTTP Request", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "web_search_tool", + display_name: "Web Search", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "screenshot", + display_name: "Screenshot", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "image_info", + display_name: "Image Info", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "delegate", + display_name: "Delegate", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "composio", + display_name: "Composio", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "hardware_board_info", + display_name: "Hardware Board Info", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "hardware_memory_map", + display_name: "Hardware Memory Map", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "hardware_memory_read", + display_name: "Hardware Memory Read", + aliases: &[], + compiled: true, + platform_supported: true, + }, + ToolDescriptor { + key: "mcp.dynamic", + display_name: "MCP Dynamic Tool", + aliases: &[], + compiled: false, + platform_supported: true, + }, +]; + +pub fn list_tools() -> &'static [ToolDescriptor] { + TOOLS +} + +pub fn resolve_tool_key(name: &str) -> Option<&'static str> { + let candidate = name.trim(); + TOOLS + .iter() + .find(|descriptor| { + descriptor.key.eq_ignore_ascii_case(candidate) + || descriptor + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(candidate)) + }) + .map(|descriptor| descriptor.key) +} + +pub fn tool_availability(name: &str) -> Option { + let key = resolve_tool_key(name)?; + TOOLS + .iter() + .find(|descriptor| descriptor.key == key) + .map(|descriptor| { + if !descriptor.platform_supported { + CapabilityAvailability::PlatformUnavailable + } else if !descriptor.compiled { + CapabilityAvailability::Uncompiled + } else { + CapabilityAvailability::Constructible + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_registry_case_insensitive_uniqueness() { + // Collect all lowercased keys and aliases + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + let mut failures: Vec = Vec::new(); + + for descriptor in TOOLS { + // Check the primary key + let key_lower = descriptor.key.to_lowercase(); + if let Some(existing) = seen.insert(key_lower, descriptor.key) { + failures.push(format!( + "Collision: key '{}' (from '{}') already registered by '{}'", + key_lower, descriptor.key, existing + )); + } + + // Check each alias + for alias in descriptor.aliases { + let alias_lower = alias.to_lowercase(); + if let Some(existing) = seen.insert(alias_lower, descriptor.key) { + failures.push(format!( + "Collision: alias '{}' (from '{}') already registered by '{}'", + alias_lower, alias, existing + )); + } + } + } + + if !failures.is_empty() { + panic!( + "Tool registry case-insensitive uniqueness violations:\n{}", + failures.join("\n") + ); + } + } +} diff --git a/clients/agent-runtime/src/agent/agent.rs b/clients/agent-runtime/src/agent/agent.rs index 01c0f4200..4f47c6908 100644 --- a/clients/agent-runtime/src/agent/agent.rs +++ b/clients/agent-runtime/src/agent/agent.rs @@ -3152,6 +3152,21 @@ mod tests { ); } + #[test] + fn full_runtime_starts_without_manifest() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp); + config.default_provider = Some("anthropic".into()); + + let agent = Agent::from_config(&config).unwrap(); + + // Canonicalize the backend key for comparison since config may use aliases + let expected_backend = corvus_memory::resolve_memory_backend_key(&config.memory.backend) + .unwrap_or(config.memory.backend.as_str()); + assert_eq!(agent.memory.name(), expected_backend); + assert!(!agent.tools.is_empty()); + } + fn build_classification_test_agent( classification_config: crate::config::QueryClassificationConfig, available_hints: Vec, diff --git a/clients/agent-runtime/src/bootstrap/composed.rs b/clients/agent-runtime/src/bootstrap/composed.rs new file mode 100644 index 000000000..ae12f8dce --- /dev/null +++ b/clients/agent-runtime/src/bootstrap/composed.rs @@ -0,0 +1,354 @@ +use crate::bootstrap::BootstrapContext; +use crate::capabilities::build_registry_from_tools; +use crate::config::{Config, SandboxBackend}; +use crate::cost::CostTracker; +use crate::memory::Memory; +use crate::observability::Observer; +use crate::providers::Provider; +use crate::runtime::{self, RuntimeAdapter}; +use crate::security::SecurityPolicy; +use crate::tools::Tool; +use anyhow::{anyhow, Context, Result}; +use corvus_composer::ComposedRuntimePlan; +use std::sync::Arc; + +pub fn bootstrap_from_plan( + base_config: &Config, + plan: &ComposedRuntimePlan, +) -> Result<(BootstrapContext, Box)> { + let config = config_from_plan(base_config, plan)?; + + build_bootstrap_and_provider(&config, plan) +} + +fn config_from_plan(base_config: &Config, plan: &ComposedRuntimePlan) -> Result { + let mut config = base_config.clone(); + config.default_provider = Some(plan.provider.key.clone()); + if let Some(model) = &plan.agent.model { + config.default_model = Some(model.clone()); + } + if let Some(temperature) = plan.agent.temperature { + config.default_temperature = temperature; + } + if let Some(profile) = &plan.runtime.profile { + config.agent.profile = profile.clone(); + } + if let Some(max_tool_iterations) = plan.runtime.max_tool_iterations { + config.agent.max_tool_iterations = max_tool_iterations; + } + // First check memory_settings.auto_save (from resolved plan), fallback to config + if let Some(auto_save) = plan.memory_settings.auto_save { + config.memory.auto_save = auto_save; + } else if let Some(auto_save) = plan + .memory + .config + .as_ref() + .and_then(|value| value.get("auto_save")) + .and_then(toml::Value::as_bool) + { + config.memory.auto_save = auto_save; + } + config.memory.backend = plan.memory.key.clone(); + + // Validate and apply observers - only single observer supported for now + if plan.observers.len() > 1 { + return Err(anyhow!( + "multiple observers not supported: found {} ({:?}), expected 1", + plan.observers.len(), + plan.observers + .iter() + .map(|o| o.key.clone()) + .collect::>() + )); + } + if let Some(observer) = plan.observers.first() { + config.observability.backend = observer.key.clone(); + if let Some(observer_config) = &observer.config { + if let Some(endpoint) = observer_config + .get("otel_endpoint") + .and_then(toml::Value::as_str) + { + config.observability.otel_endpoint = Some(endpoint.to_string()); + } + if let Some(service_name) = observer_config + .get("otel_service_name") + .and_then(toml::Value::as_str) + { + config.observability.otel_service_name = Some(service_name.to_string()); + } + } + } + + // Validate and apply security backend - fail on unknown keys instead of silently defaulting + config.security.sandbox.backend = match plan.security.key.as_str() { + "landlock" => SandboxBackend::Landlock, + "firejail" => SandboxBackend::Firejail, + "bubblewrap" => SandboxBackend::Bubblewrap, + "docker" => SandboxBackend::Docker, + "none" => SandboxBackend::None, + unknown => { + return Err(anyhow!( + "unknown security backend: '{}', valid options are: landlock, firejail, bubblewrap, docker, none", + unknown + )) + } + }; + if let Some(require) = plan + .security + .config + .as_ref() + .and_then(|value| value.get("require")) + .and_then(toml::Value::as_bool) + { + config.security.sandbox.require = require; + } + if let Some(format) = &plan.identity.format { + config.identity.format = format.clone(); + } + config.identity.aieos_path = plan.identity.aieos_path.clone(); + config.identity.aieos_inline = plan.identity.aieos_inline.clone(); + + Ok(config) +} + +fn build_bootstrap_and_provider( + config: &Config, + plan: &ComposedRuntimePlan, +) -> Result<(BootstrapContext, Box)> { + let sandbox = crate::security::create_sandbox(&config.security)?; + let observer: Arc = + Arc::from(crate::observability::create_observer(&config.observability)); + let memory: Arc = Arc::from(crate::memory::create_memory( + &config.memory, + &config.workspace_dir, + config.api_key.as_deref(), + )?); + let runtime: Arc = Arc::from(runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) + } else { + (None, None) + }; + let all_tools = crate::tools::all_tools_with_runtime( + Arc::new(config.clone()), + &security, + Arc::clone(&runtime), + sandbox, + Arc::clone(&memory), + composio_key, + composio_entity_id, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + config, + ); + let selected_tool_keys: Vec = plan.tools.iter().map(|tool| tool.key.clone()).collect(); + let tools: Vec> = all_tools + .into_iter() + .filter(|tool| { + selected_tool_keys + .iter() + .any(|selected| selected == tool.name()) + }) + .collect(); + if tools.len() != selected_tool_keys.len() { + let actual: Vec<&str> = tools.iter().map(|tool| tool.name()).collect(); + return Err(anyhow!("manifest-selected tools could not all be materialized: requested {:?}, materialized {:?}", selected_tool_keys, actual)); + } + + let capability_registry = build_registry_from_tools(&tools)?; + let cost_tracker = if config.cost.enabled { + match CostTracker::new(config.cost.clone(), &config.workspace_dir) { + Ok(tracker) => Some(Arc::new(tracker)), + Err(error) => { + tracing::warn!("Failed to initialize cost tracker: {error}"); + None + } + } + } else { + None + }; + + let provider_api_url = plan + .provider + .config + .as_ref() + .and_then(|value| value.get("api_url")) + .and_then(toml::Value::as_str) + .or(config.api_url.as_deref()); + let provider_api_key = plan + .provider + .config + .as_ref() + .and_then(|value| value.get("api_key")) + .and_then(toml::Value::as_str) + .or(config.api_key.as_deref()); + let provider = crate::providers::create_provider_with_url( + &plan.provider.key, + provider_api_key, + provider_api_url, + ) + .with_context(|| format!("failed to create composed provider '{}'", plan.provider.key))?; + + Ok(( + BootstrapContext { + observer, + runtime, + security, + memory, + tools, + capability_registry, + cost_tracker, + }, + provider, + )) +} + +pub fn agent_from_plan( + base_config: &Config, + plan: &ComposedRuntimePlan, +) -> Result { + let config = config_from_plan(base_config, plan)?; + let (bootstrap, provider) = build_bootstrap_and_provider(&config, plan)?; + crate::agent::Agent::from_bootstrap_with_provider(&config, bootstrap, provider) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootstrap::BootstrapContext; + use crate::test_support::test_config; + use corvus_composer::AgentComposer; + + fn lite_manifest() -> &'static str { + r#" +version = "1" + +[agent] +name = "lite-agent" +model = "anthropic/claude-sonnet-4" +profile = "lite" + +[providers] +enabled = ["anthropic"] +default = "anthropic" + +[channels] +enabled = ["stdio", "telegram"] +default = "stdio" + +[tools] +enabled = ["shell", "file_read", "file_write"] + +[memory] +backend = "none" + +[observability] +enabled = ["none"] + +[security] +backend = "none" +"# + } + + #[test] + fn composed_bootstrap_materializes_selected_components_only() { + let tempdir = tempfile::TempDir::new().unwrap(); + let config = test_config(&tempdir); + let manifest = r#" +version = "1" + +[agent] +name = "boot-agent" +model = "anthropic/claude-sonnet-4" +profile = "code" + +[providers] +enabled = ["anthropic"] +default = "anthropic" + +[channels] +enabled = ["stdio"] + +[tools] +enabled = ["shell", "file_read"] + +[memory] +backend = "markdown" + +[observability] +enabled = ["none"] + +[security] +backend = "none" +"#; + let composer = AgentComposer::from_toml(manifest).unwrap(); + let (bootstrap, _provider) = bootstrap_from_plan(&config, composer.resolve_plan()).unwrap(); + + assert_eq!(bootstrap.memory.name(), "markdown"); + let tool_names: Vec<&str> = bootstrap.tools.iter().map(|tool| tool.name()).collect(); + assert_eq!(tool_names, vec!["shell", "file_read"]); + } + + #[test] + fn composed_plan_preserves_channel_selection_for_future_runtime_wiring() { + let composer = AgentComposer::from_toml(lite_manifest()).unwrap(); + let channel_keys: Vec<&str> = composer + .resolve_plan() + .channels + .iter() + .map(|channel| channel.key.as_str()) + .collect(); + + assert_eq!(channel_keys, vec!["stdio", "telegram"]); + assert_eq!( + composer.resolve_plan().default_channel.as_deref(), + Some("stdio") + ); + } + + #[test] + fn composed_bootstrap_matches_full_runtime_for_lite_profile_path() { + let tempdir = tempfile::TempDir::new().unwrap(); + let mut config = test_config(&tempdir); + config.agent.profile = "lite".into(); + config.default_provider = Some("anthropic".into()); + config.observability.backend = "none".into(); + config.memory.backend = "none".into(); + + let composer = AgentComposer::from_toml(lite_manifest()).unwrap(); + let (composed_bootstrap, _provider) = + bootstrap_from_plan(&config, composer.resolve_plan()).unwrap(); + let full_bootstrap = BootstrapContext::from_config(&config).unwrap(); + + let composed_tool_names: Vec<&str> = composed_bootstrap + .tools + .iter() + .map(|tool| tool.name()) + .collect(); + let full_tool_names: Vec<&str> = full_bootstrap + .tools + .iter() + .map(|tool| tool.name()) + .collect(); + + assert_eq!( + composed_bootstrap.memory.name(), + full_bootstrap.memory.name() + ); + assert_eq!( + composed_bootstrap.observer.name(), + full_bootstrap.observer.name() + ); + assert_eq!(composed_tool_names, full_tool_names); + } +} diff --git a/clients/agent-runtime/src/bootstrap/mod.rs b/clients/agent-runtime/src/bootstrap/mod.rs index 4993189d0..00f4c3e1a 100644 --- a/clients/agent-runtime/src/bootstrap/mod.rs +++ b/clients/agent-runtime/src/bootstrap/mod.rs @@ -11,6 +11,8 @@ use anyhow::bail; use std::path::PathBuf; use std::sync::Arc; +pub mod composed; + pub const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/clients/agent-runtime/src/channels/mod.rs b/clients/agent-runtime/src/channels/mod.rs index 4718c3150..08cec6826 100644 --- a/clients/agent-runtime/src/channels/mod.rs +++ b/clients/agent-runtime/src/channels/mod.rs @@ -32,6 +32,8 @@ pub use telegram::TelegramChannel; pub use traits::{Channel, SendMessage}; pub use whatsapp::WhatsAppChannel; +use corvus_channels::select_channel; + use crate::agent::dispatcher::{ DispatchAction, NativeToolDispatcher, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher, }; @@ -2753,18 +2755,18 @@ fn build_doctor_channels(config: &Config) -> Vec { } pub(crate) fn build_channel(config: &Config, channel_name: &str) -> Option> { - let channel_name = channel_name.to_ascii_lowercase(); - CHANNEL_REGISTRY - .iter() - .find(|entry| entry.key == channel_name.as_str()) - .and_then(|entry| (entry.build)(config)) + // Use select_channel to align discovery and construction via the same registry + select_channel(channel_name).ok().and_then(|selected| { + CHANNEL_REGISTRY + .iter() + .find(|entry| entry.key == selected.key) + .and_then(|entry| (entry.build)(config)) + }) } pub(crate) fn is_supported_channel(channel_name: &str) -> bool { - let channel_name = channel_name.to_ascii_lowercase(); - CHANNEL_REGISTRY - .iter() - .any(|entry| entry.key == channel_name.as_str()) + // Use select_channel for consistency - returns Ok if channel is constructible + select_channel(channel_name).is_ok() } /// Run health checks for configured channels. diff --git a/clients/agent-runtime/src/composer.rs b/clients/agent-runtime/src/composer.rs index e91c7dc38..fad189ade 100644 --- a/clients/agent-runtime/src/composer.rs +++ b/clients/agent-runtime/src/composer.rs @@ -1,59 +1,40 @@ //! Composer CLI integration - handles `corvus agent build`, `corvus agent run`, `corvus agent new` -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::Parser; use std::path::{Path, PathBuf}; use tracing::info; -use corvus_composer::{AgentComposer, CapabilityReport, ValidationError}; - -// Re-export constants for local use -use corvus_composer::KNOWN_CHANNELS; -use corvus_composer::KNOWN_PROVIDERS; -use corvus_composer::KNOWN_TOOLS; +use corvus_composer::{AgentComposer, ValidationError}; /// Agent composition subcommands #[derive(Parser, Debug)] pub enum ComposerCommands { /// Build an agent from a manifest Build { - /// Path to agent manifest TOML file #[arg(long)] manifest: PathBuf, - - /// Output directory for compiled agent #[arg(long)] output: Option, }, - /// Run an agent directly from a manifest (boot-time composition) Run { - /// Path to agent manifest TOML file #[arg(long)] manifest: PathBuf, }, - /// Create a new agent from a template New { - /// Template name (e.g., chat-bot, support-bot) #[arg(long)] template: String, - - /// Agent name #[arg(long)] name: String, - - /// Output directory (optional) #[arg(long)] output: Option, }, } -/// Known agent templates const KNOWN_TEMPLATES: &[&str] = &["chat-bot", "support-bot", "code-assistant"]; -/// Handle agent composition commands -// async is intentional: this is a public dispatch boundary; inner handlers will be async in Phase 5 #[allow(clippy::unused_async)] pub async fn handle_composer_command(command: ComposerCommands) -> Result<()> { match command { @@ -67,578 +48,279 @@ pub async fn handle_composer_command(command: ComposerCommands) -> Result<()> { } } -/// Handle `corvus agent build --manifest --output ` +fn load_composer(manifest: &Path) -> Result { + let content = std::fs::read_to_string(manifest) + .with_context(|| format!("failed to read manifest from {}", manifest.display()))?; + AgentComposer::from_toml(&content).map_err(|error| { + anyhow::anyhow!( + "Invalid manifest: {error}\n\nLocation: {}", + manifest.display() + ) + }) +} + fn handle_build_command(manifest: PathBuf, output: Option) -> Result<()> { info!("Building agent from manifest: {}", manifest.display()); - - // Parse and validate the manifest - let composer = match AgentComposer::from_path(&manifest) { - Ok(c) => c, - Err(e) => { - bail!(format!( - "Failed to load manifest: {}\n\nHint: Use --manifest with a valid TOML file", - e - )); - } - }; - - // Check for validation errors - if let Err(e) = composer.validate() { - bail!(format_manifest_validation_error(&e, &manifest)); - } - - // Emit warnings - let warnings = composer.validate_with_warnings(); - for warning in &warnings { - eprintln!("Warning: {} - {}", warning.field, warning.message); - } - - // Get required capabilities - let capabilities = composer.required_capabilities(); - - // Check if required capabilities are available - check_capabilities_available(capabilities, &manifest)?; - - // Build the agent + let composer = load_composer(&manifest)?; + composer + .validate() + .map_err(|error| anyhow::anyhow!(format_manifest_validation_error(&error, &manifest)))?; let output_dir = - output.unwrap_or_else(|| PathBuf::from("target").join(composer.manifest().name.as_str())); - - info!( - "Building agent '{}' to {}", - composer.manifest().name, - output_dir.display() - ); + output.unwrap_or_else(|| PathBuf::from("target").join(&composer.manifest().agent.name)); build_composed_agent(&composer, &output_dir)?; - println!( - "Agent built successfully: - name={} - manifest={} - output={}", - composer.manifest().name, + "Agent build plan is valid:\n name={}\n manifest={}\n output={}", + composer.manifest().agent.name, manifest.display(), output_dir.display() ); - Ok(()) } -/// Handle `corvus agent run --manifest ` fn handle_run_command(manifest: PathBuf) -> Result<()> { - info!("Running agent from manifest: {}", manifest.display()); - - // Parse and validate the manifest - let composer = match AgentComposer::from_path(&manifest) { - Ok(c) => c, - Err(e) => { - bail!(format!( - "Failed to load manifest: {}\n\nHint: Use --manifest with a valid TOML file", - e - )); - } - }; - - if let Err(e) = composer.validate() { - bail!(format_manifest_validation_error(&e, &manifest)); - } - - // Check for warnings - let warnings = composer.validate_with_warnings(); - for warning in &warnings { - eprintln!("Warning: {} - {}", warning.field, warning.message); - } - - // Check platform constraints (sandbox, etc.) - check_platform_constraints(&composer)?; - - // Get required capabilities - let capabilities = composer.required_capabilities(); - check_capabilities_available(capabilities, &manifest)?; + let config = crate::Config::load_or_init() + .context("failed to load runtime config for composed agent")?; + handle_run_command_with_config(manifest, config) +} - // Run directly from manifest (boot-time composition) - info!("Boot-time agent composition not yet implemented"); - bail!(format!( - "Agent '{}' configured but run is not yet implemented.\n\ - Use `corvus agent build --manifest {}` to build the agent first.", - composer.manifest().name, - manifest.display() - )); +fn handle_run_command_with_config(manifest: PathBuf, _config: crate::Config) -> Result<()> { + info!("Running agent from manifest: {}", manifest.display()); + let composer = load_composer(&manifest)?; + composer + .validate() + .map_err(|error| anyhow::anyhow!(format_manifest_validation_error(&error, &manifest)))?; + + // Running composed agents via CLI is not yet implemented. + // The composed agent (BootstrapContext + Provider) is created successfully, + // but wiring it into the interactive/runtime loop requires additional integration work. + // See: handle_run_command_with_config and agent_from_plan in bootstrap/composed.rs + Err(anyhow::anyhow!( + "running composed agents is not yet supported via CLI; 'corvus agent run' creates the agent but does not execute it. Use 'corvus agent build' to validate the manifest, or use 'corvus' directly for interactive agent sessions." + )) } -/// Handle `corvus agent new --template --name ` fn handle_new_command(template: String, name: String, output: Option) -> Result<()> { - // Validate template if !KNOWN_TEMPLATES.contains(&template.as_str()) { - bail!(format!( + bail!( "Unknown template: '{}'\n\nAvailable templates: {}", template, KNOWN_TEMPLATES.join(", ") - )); + ); } - - // Validate agent name if name.is_empty() { bail!("Agent name cannot be empty"); } - if name.contains('/') || name.contains('\\') { - bail!( - "Agent name '{}' contains invalid characters. Use only alphanumeric, underscore, and hyphen.", - name - ); + bail!("Agent name '{}' contains invalid characters. Use only alphanumeric, underscore, and hyphen.", name); } - info!("Creating agent '{}' from template '{}'", name, template); - - // Generate manifest from template let manifest_content = generate_template_manifest(&template, &name)?; let output_path = output - .or_else(|| Some(PathBuf::from("agents"))) - .unwrap() + .unwrap_or_else(|| PathBuf::from("agents")) .join(format!("{}.toml", name)); - - // Ensure directory exists if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent)?; } - - // Write manifest std::fs::write(&output_path, manifest_content)?; - info!("Created manifest at: {}", output_path.display()); - println!( - "Agent '{}' created: - template={} - manifest={}", + "Agent '{}' created:\n template={}\n manifest={}", name, template, output_path.display() ); - Ok(()) } -/// Format validation error with helpful hints fn format_manifest_validation_error(error: &ValidationError, manifest: &Path) -> String { - let base_msg = format!( + format!( "Invalid manifest: {}\n\nLocation: {}", error, manifest.display() - ); - - match error { - ValidationError::NoProviders => format!( - "{}\n\nHint: Add at least one provider in [providers] section", - base_msg - ), - ValidationError::NoChannels => format!( - "{}\n\nHint: Add at least one channel in [channels] section", - base_msg - ), - ValidationError::NoTools => format!( - "{}\n\nHint: Add at least one tool in [tools] section", - base_msg - ), - ValidationError::DefaultProviderDisabled { name } => format!( - "{}\n\nHint: Ensure '{}' is in the providers list", - base_msg, name - ), - ValidationError::UnknownCapability { name, kind } => format!( - "{}\n\nHint: '{}' is not a known {}. Check the capability registry.", - base_msg, name, kind - ), - ValidationError::InvalidMemoryBackend { backend: _ } => format!( - "{}\n\nHint: Use a valid memory backend: sqlite, none", - base_msg - ), - ValidationError::InvalidSandboxBackend { sandbox: _ } => format!( - "{}\n\nHint: Use a valid sandbox: wasmi, landlock, bubblewrap, none", - base_msg - ), - ValidationError::ToolRestrictionsNotSubset { .. } => format!( - "{}\n\nHint: Tool restrictions must be a subset of enabled tools", - base_msg - ), - ValidationError::InlineSecret { field } => format!( - "{}\n\nHint: Inline secrets not allowed for '{}'. Use environment variable references.", - base_msg, field - ), - ValidationError::NoDefaultChannel => format!( - "{}\n\nHint: Set a default channel when multiple channels are configured", - base_msg - ), - } + ) } -/// Check if required capabilities are available/compiled -fn check_capabilities_available(capabilities: &CapabilityReport, _manifest: &Path) -> Result<()> { - // For now, we check if the manifest references are known - // Full capability availability check would require runtime introspection - - let missing_providers: Vec<&String> = capabilities - .providers - .iter() - .filter(|p| !is_capability_available("provider", p)) - .collect(); - - if !missing_providers.is_empty() { - bail!(format!( - "Missing required provider(s): {:?}\n \ - Hint: Ensure the provider is compiled and registered.", - missing_providers - )); - } - - let missing_channels: Vec<&String> = capabilities - .channels - .iter() - .filter(|c| !is_capability_available("channel", c)) - .collect(); - - if !missing_channels.is_empty() { - bail!(format!( - "Missing required channel(s): {:?}\n \ - Hint: Ensure the channel is configured in the runtime.", - missing_channels - )); - } - - let missing_tools: Vec<&String> = capabilities - .tools - .iter() - .filter(|t| !is_capability_available("tool", t)) - .collect(); - - if !missing_tools.is_empty() { - bail!(format!( - "Missing required tool(s): {:?}\n \ - Hint: Ensure the tool is compiled and enabled.", - missing_tools - )); - } - - Ok(()) -} - -/// Check if a capability is available (simplified check) -fn is_capability_available(kind: &str, name: &str) -> bool { - let known: &[&str] = match kind { - "provider" => KNOWN_PROVIDERS, - "channel" => KNOWN_CHANNELS, - "tool" => KNOWN_TOOLS, - _ => return false, - }; - - known.contains(&name) -} - -/// Check platform-specific constraints -fn check_platform_constraints(composer: &AgentComposer) -> Result<()> { - // Check sandbox availability - if let Some(security) = &composer.manifest().security { - if let Some(sandbox) = &security.sandbox { - if !sandbox.is_empty() { - // Check if sandbox is available on this platform - let sandbox_available = check_sandbox_availability(sandbox); - if !sandbox_available { - bail!(format!( - "Sandbox '{}' not available on this platform\n\n\ - Hint: Use 'none' or a platform-compatible sandbox.", - sandbox - )); - } - } - } - } - - Ok(()) -} - -/// Check if sandbox is available on current platform -fn check_sandbox_availability(sandbox: &str) -> bool { - match sandbox { - "none" => true, - #[cfg(target_os = "linux")] - "wasmi" | "landlock" | "bubblewrap" => true, - #[cfg(not(target_os = "linux"))] - "wasmi" => true, - #[cfg(not(target_os = "linux"))] - "landlock" | "bubblewrap" => false, - _ => false, - } -} - -/// Build composed agent (placeholder for Phase 5) fn build_composed_agent(composer: &AgentComposer, _output_dir: &PathBuf) -> Result<()> { - // This is where we would actually compose and build the agent - // For now, just validate and report success - info!("Building agent '{}'", composer.manifest().name); - - // Phase 5 will implement actual build using registry resolution. - // Requires access to: provider registry, channel registry, - // tool registry, memory, observer, security - + info!( + "Validated composed agent plan '{}': {:?}", + composer.manifest().agent.name, + composer.required_capabilities() + ); Ok(()) } -/// Generate manifest from template fn generate_template_manifest(template: &str, name: &str) -> Result { - let template_content = match template { + let manifest = match template { "chat-bot" => format!( - r#"# Agent manifest for {} -# Generated from template: chat-bot + r#"version = "1" -version = "1.0" -name = "{}" +[agent] +name = "{name}" description = "A simple chat bot agent" +model = "anthropic/claude-sonnet-4" +temperature = 0.7 [providers] -providers = ["anthropic"] +enabled = ["anthropic"] default = "anthropic" -model = "claude-sonnet-4-20250514" -temperature = 0.7 [channels] -channels = ["telegram"] +enabled = ["telegram"] +default = "telegram" [tools] -tools = ["shell", "file_read", "file_write", "memory_recall", "memory_store"] -"#, - name, name +enabled = ["shell", "file_read", "file_write", "memory_recall", "memory_store"] + +[memory] +backend = "markdown" + +[observability] +enabled = ["log"] + +[security] +backend = "none" +"# ), "support-bot" => format!( - r#"# Agent manifest for {} -# Generated from template: support-bot + r#"version = "1" -version = "1.0" -name = "{}" +[agent] +name = "{name}" description = "A customer support bot agent" +model = "anthropic/claude-sonnet-4" +temperature = 0.3 [providers] -providers = ["anthropic"] +enabled = ["anthropic"] default = "anthropic" -model = "claude-sonnet-4-20250514" -temperature = 0.3 [channels] -channels = ["telegram", "discord"] +enabled = ["telegram", "discord"] default = "telegram" [tools] -tools = ["shell", "file_read", "file_write", "memory_recall", "memory_store", "web_search_tool"] +enabled = ["shell", "file_read", "file_write", "memory_recall", "memory_store", "web_search_tool"] [memory] backend = "sqlite" +[observability] +enabled = ["log"] + [security] -sandbox = "none" -"#, - name, name +backend = "none" +"# ), "code-assistant" => format!( - r#"# Agent manifest for {} -# Generated from template: code-assistant + r#"version = "1" -version = "1.0" -name = "{}" +[agent] +name = "{name}" description = "A code assistant agent" +model = "anthropic/claude-sonnet-4" +temperature = 0.2 +profile = "code" [providers] -providers = ["anthropic"] +enabled = ["anthropic"] default = "anthropic" -model = "claude-sonnet-4-20250514" -temperature = 0.2 [channels] -channels = ["stdio"] +enabled = ["stdio"] +default = "stdio" [tools] -tools = ["shell", "file_read", "file_write", "git_operations"] +enabled = ["shell", "file_read", "file_write", "git_operations"] [memory] backend = "none" +[observability] +enabled = ["none"] + [security] -sandbox = "none" -"#, - name, name +backend = "none" +"# ), _ => bail!("Unknown template: {}", template), }; - - Ok(template_content) + Ok(manifest) } #[cfg(test)] mod tests { use super::*; + use crate::test_support::test_config; use std::io::Write; use tempfile::NamedTempFile; - // --- generate_template_manifest --- - #[test] - fn generate_chat_bot_manifest_contains_expected_fields() { + fn generate_chat_bot_manifest_uses_v1_sections() { let content = generate_template_manifest("chat-bot", "my-bot").unwrap(); - assert!(content.contains("name = \"my-bot\"")); - assert!(content.contains("anthropic")); - assert!(content.contains("telegram")); - } - - #[test] - fn generate_support_bot_manifest_contains_expected_fields() { - let content = generate_template_manifest("support-bot", "acme-support").unwrap(); - assert!(content.contains("name = \"acme-support\"")); - assert!(content.contains("telegram")); - assert!(content.contains("discord")); - assert!(content.contains("sqlite")); - } - - #[test] - fn generate_code_assistant_manifest_contains_expected_fields() { - let content = generate_template_manifest("code-assistant", "my-coder").unwrap(); - assert!(content.contains("name = \"my-coder\"")); - assert!(content.contains("stdio")); - assert!(content.contains("git_operations")); + assert!(content.contains("[agent]")); + assert!(content.contains("[providers]")); + assert!(content.contains("[security]")); } - #[test] - fn generate_unknown_template_returns_error() { - let result = generate_template_manifest("nonexistent", "bot"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Unknown template")); - } - - // --- handle_new_command --- - #[test] fn new_command_creates_manifest_file() { let dir = tempfile::tempdir().unwrap(); - let result = handle_new_command( + handle_new_command( "chat-bot".into(), "test-agent".into(), Some(dir.path().to_path_buf()), - ); - assert!(result.is_ok(), "unexpected error: {:?}", result); - let manifest_path = dir.path().join("test-agent.toml"); - assert!( - manifest_path.exists(), - "manifest file should have been created" - ); - let content = std::fs::read_to_string(&manifest_path).unwrap(); - assert!(content.contains("name = \"test-agent\"")); - } - - #[test] - fn new_command_rejects_empty_name() { - let result = handle_new_command("chat-bot".into(), String::new(), None); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("empty")); - } - - #[test] - fn new_command_rejects_invalid_name_with_slash() { - let result = handle_new_command("chat-bot".into(), "foo/bar".into(), None); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("invalid characters")); - } - - #[test] - fn new_command_rejects_unknown_template() { - let result = handle_new_command("bogus-template".into(), "bot".into(), None); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("Unknown template") || msg.contains("bogus-template")); + ) + .unwrap(); + assert!(dir.path().join("test-agent.toml").exists()); } - // --- handle_build_command / handle_run_command with valid manifest --- - - fn write_minimal_manifest() -> NamedTempFile { - let mut f = NamedTempFile::new().unwrap(); + fn write_manifest() -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); writeln!( - f, - r#"version = "1.0" -name = "test-agent" -description = "test" - -[providers] -providers = ["anthropic"] -default = "anthropic" -model = "claude-opus-4" - -[channels] -channels = ["stdio"] - -[tools] -tools = ["shell"] -"# + file, + "{}", + generate_template_manifest("code-assistant", "composed-agent").unwrap() ) .unwrap(); - f + file } #[test] fn build_command_succeeds_with_valid_manifest() { - let manifest_file = write_minimal_manifest(); + let manifest = write_manifest(); let out_dir = tempfile::tempdir().unwrap(); let result = handle_build_command( - manifest_file.path().to_path_buf(), + manifest.path().to_path_buf(), Some(out_dir.path().to_path_buf()), ); - assert!(result.is_ok(), "unexpected error: {:?}", result); + assert!(result.is_ok(), "unexpected error: {result:?}"); } #[test] - fn build_command_fails_with_missing_manifest() { - let result = handle_build_command( - std::path::PathBuf::from("/nonexistent/path/agent.toml"), - None, + fn run_command_composes_agent_with_existing_builder_seam() { + let tempdir = tempfile::tempdir().unwrap(); + let mut config = test_config(&tempdir); + config.default_provider = Some("anthropic".to_string()); + let manifest = write_manifest(); + let result = handle_run_command_with_config(manifest.path().to_path_buf(), config); + assert!( + result.is_err(), + "expected error because composed agents are not yet supported via CLI" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("not yet supported via CLI"), + "unexpected error message: {err_msg}" ); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Failed to load manifest")); - } - - #[test] - fn run_command_fails_gracefully_not_yet_implemented() { - let manifest_file = write_minimal_manifest(); - let result = handle_run_command(manifest_file.path().to_path_buf()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("not yet implemented") || msg.contains("build")); } #[test] - fn run_command_fails_with_missing_manifest() { - let result = handle_run_command(std::path::PathBuf::from("/no/such/file.toml")); + fn run_command_reports_validation_failures() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "version = \"9\"\n[agent]\nname = \"oops\"\n[providers]\nenabled=[\"anthropic\"]\ndefault=\"anthropic\"\n[channels]\nenabled=[\"stdio\"]\n[memory]\nbackend=\"none\"").unwrap(); + let tempdir = tempfile::tempdir().unwrap(); + let result = + handle_run_command_with_config(file.path().to_path_buf(), test_config(&tempdir)); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Failed to load manifest")); - } - - // --- check_sandbox_availability --- - - #[test] - fn sandbox_none_is_always_available() { - assert!(check_sandbox_availability("none")); - } - - #[test] - fn unknown_sandbox_is_not_available() { - assert!(!check_sandbox_availability("hypervisor-xyz")); - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn linux_only_sandboxes_unavailable_on_non_linux() { - assert!(!check_sandbox_availability("landlock")); - assert!(!check_sandbox_availability("bubblewrap")); + assert!(result.unwrap_err().to_string().contains("Invalid manifest")); } } diff --git a/clients/agent-runtime/src/memory/mod.rs b/clients/agent-runtime/src/memory/mod.rs index 56ec97727..b7b59b0fa 100644 --- a/clients/agent-runtime/src/memory/mod.rs +++ b/clients/agent-runtime/src/memory/mod.rs @@ -71,16 +71,21 @@ pub fn create_memory( workspace_dir: &Path, api_key: Option<&str>, ) -> anyhow::Result> { + let canonical_backend = corvus_memory::resolve_memory_backend_key(&config.backend) + .unwrap_or(config.backend.as_str()) + .to_string(); + let mut effective_config = config.clone(); + effective_config.backend = canonical_backend; if cerebro_configured(config) { tracing::info!("Cerebro MCP configured; local memory remains short-term only"); } // Best-effort memory hygiene/retention pass (throttled by state file). - if let Err(e) = hygiene::run_if_due(config, workspace_dir) { + if let Err(e) = hygiene::run_if_due(&effective_config, workspace_dir) { tracing::warn!("memory hygiene skipped: {e}"); } // If snapshot_on_hygiene is enabled, export core memories during hygiene. - if config.snapshot_enabled && config.snapshot_on_hygiene { + if effective_config.snapshot_enabled && effective_config.snapshot_on_hygiene { if let Err(e) = snapshot::export_snapshot(workspace_dir) { tracing::warn!("memory snapshot skipped: {e}"); } @@ -88,9 +93,9 @@ pub fn create_memory( // Auto-hydration: if brain.db is missing but MEMORY_SNAPSHOT.md exists, // restore the "soul" from the snapshot before creating the backend. - if config.auto_hydrate + if effective_config.auto_hydrate && matches!( - classify_memory_backend(&config.backend), + classify_memory_backend(&effective_config.backend), MemoryBackendKind::Sqlite | MemoryBackendKind::Lucid ) && snapshot::should_hydrate(workspace_dir) @@ -108,14 +113,14 @@ pub fn create_memory( } } - match classify_memory_backend(&config.backend) { + match classify_memory_backend(&effective_config.backend) { MemoryBackendKind::Sqlite => Ok(Box::new(build_sqlite_memory( - config, + &effective_config, workspace_dir, api_key, )?)), MemoryBackendKind::Lucid => { - let local = build_sqlite_memory(config, workspace_dir, api_key)?; + let local = build_sqlite_memory(&effective_config, workspace_dir, api_key)?; Ok(Box::new(LucidMemory::new(workspace_dir, local))) } MemoryBackendKind::Markdown => Ok(Box::new(MarkdownMemory::new(workspace_dir))), @@ -128,7 +133,7 @@ pub fn create_memory( MemoryBackendKind::Unknown => { tracing::warn!( "Unknown memory backend '{}', falling back to markdown", - config.backend + effective_config.backend ); Ok(Box::new(MarkdownMemory::new(workspace_dir))) } diff --git a/clients/agent-runtime/src/observability/mod.rs b/clients/agent-runtime/src/observability/mod.rs index 0258033ce..241af469d 100755 --- a/clients/agent-runtime/src/observability/mod.rs +++ b/clients/agent-runtime/src/observability/mod.rs @@ -28,7 +28,9 @@ use crate::config::ObservabilityConfig; /// Factory: create the right observer from config pub fn create_observer(config: &ObservabilityConfig) -> Box { - match config.backend.as_str() { + let backend = corvus_observability::resolve_observer_key(&config.backend) + .unwrap_or(config.backend.as_str()); + match backend { "log" => Box::new(LogObserver::new()), "prometheus" => Box::new(PrometheusObserver::new()), "otel" | "opentelemetry" | "otlp" => { diff --git a/clients/agent-runtime/src/providers/mod.rs b/clients/agent-runtime/src/providers/mod.rs index 36c2b1fdb..a9e4ac98b 100755 --- a/clients/agent-runtime/src/providers/mod.rs +++ b/clients/agent-runtime/src/providers/mod.rs @@ -420,6 +420,7 @@ pub fn create_provider_with_url( api_key: Option<&str>, api_url: Option<&str>, ) -> anyhow::Result> { + let name = corvus_providers::resolve_provider_key(name).unwrap_or(name); let resolved_credential = resolve_provider_credential(name, api_key); #[allow(clippy::option_as_ref_deref)] let key = resolved_credential.as_ref().map(String::as_str); diff --git a/clients/agent-runtime/src/security/detect.rs b/clients/agent-runtime/src/security/detect.rs index 227d8a6bc..7aa8609d6 100755 --- a/clients/agent-runtime/src/security/detect.rs +++ b/clients/agent-runtime/src/security/detect.rs @@ -11,7 +11,9 @@ use std::sync::Arc; /// OS-level backend is available. Returns `Ok(NoopSandbox)` when /// `require == false` and no backend is found. pub fn create_sandbox(config: &SecurityConfig) -> Result> { - let backend = &config.sandbox.backend; + // Use the typed config value directly - no need to re-resolve through registry + // as the config already has the validated SandboxBackend enum value. + let backend = config.sandbox.backend.clone(); let require = config.sandbox.require; // If explicitly disabled or backend=None, return noop or error diff --git a/clients/agent-runtime/src/security/policy.rs b/clients/agent-runtime/src/security/policy.rs index 45f049a75..f733d89e0 100644 --- a/clients/agent-runtime/src/security/policy.rs +++ b/clients/agent-runtime/src/security/policy.rs @@ -1939,9 +1939,15 @@ fn is_command_allowed_blocks_path_in_flags() { assert!(!p.is_command_allowed("grep pattern /etc/passwd")); // Case 2: Path in flag - CURRENTLY BYPASSES (this test should fail if my hypothesis is correct) - assert!(!p.is_command_allowed("grep --file=/etc/passwd pattern"), "Should block absolute path in flag"); + assert!( + !p.is_command_allowed("grep --file=/etc/passwd pattern"), + "Should block absolute path in flag" + ); // Case 3: git -C/etc status - CURRENTLY BYPASSES p.allowed_commands.push("git".into()); - assert!(!p.is_command_allowed("git -C/etc status"), "Should block absolute path in short flag"); + assert!( + !p.is_command_allowed("git -C/etc status"), + "Should block absolute path in short flag" + ); } diff --git a/clients/web/apps/dashboard/e2e/accessibility-smoke.spec.ts b/clients/web/apps/dashboard/e2e/accessibility-smoke.spec.ts new file mode 100644 index 000000000..d4875d20e --- /dev/null +++ b/clients/web/apps/dashboard/e2e/accessibility-smoke.spec.ts @@ -0,0 +1,56 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +async function stubDashboardBootstrap(page: import("@playwright/test").Page) { + await page.route("**/web/admin/options", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + memory_backends: ["sqlite", "none"], + observability_backends: ["none", "log"], + runtime_kinds: ["native", "docker"], + autonomy_levels: ["readonly", "supervised", "full"], + }), + }); + }); + + await page.route("**/web/admin/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + config: { + default_provider: "openrouter", + default_model: "anthropic/claude-sonnet-4", + channels: { webhook: { enabled: false, port: 3000, has_secret: false } }, + }, + }), + }); + }); +} + +test("supports skip navigation and accessible auth field semantics", async ({ page }) => { + await stubDashboardBootstrap(page); + + await page.goto("/"); + + await page.keyboard.press("Tab"); + const skipLink = page.getByRole("link", { name: "Skip to main content" }); + await expect(skipLink).toBeFocused(); + + await page.keyboard.press("Enter"); + await expect(page.locator("#main-content")).toBeFocused(); + + const baseUrlInput = page.locator('input[autocomplete="url"]'); + const pairingCodeInput = page.locator('input[autocomplete="one-time-code"]'); + const bearerTokenInput = page.locator('input[aria-describedby="auth-bearer-token-help"]'); + + await expect(baseUrlInput).toHaveAttribute("type", "url"); + await expect(pairingCodeInput).toHaveAttribute("type", "password"); + await expect(bearerTokenInput).toHaveAttribute("type", "password"); + await expect(page.locator("#auth-bearer-token-help")).toBeVisible(); + + const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/clients/web/apps/dashboard/package.json b/clients/web/apps/dashboard/package.json index 88556cddd..1830c4baa 100644 --- a/clients/web/apps/dashboard/package.json +++ b/clients/web/apps/dashboard/package.json @@ -14,8 +14,10 @@ "format": "biome format --write src package.json components.json tsconfig*.json vite.config.ts index.html", "check": "biome check src package.json components.json tsconfig*.json vite.config.ts index.html", "test": "vitest --run --environment happy-dom --exclude e2e/**", - "test:coverage": "vitest --run --environment happy-dom --exclude e2e/** --coverage", + "test:a11y": "vitest --run --environment happy-dom src/App.spec.ts src/components/memory/MemoryList.spec.ts src/components/chat/ChatWorkspace.spec.ts", + "test:coverage": "node ./scripts/run-coverage.mjs", "test:e2e": "pnpm exec playwright test", + "test:e2e:a11y": "pnpm exec playwright test accessibility-smoke.spec.ts", "test:e2e:install": "pnpm exec playwright install chromium" }, "dependencies": { @@ -30,6 +32,7 @@ }, "devDependencies": { "@biomejs/biome": "catalog:", + "@axe-core/playwright": "catalog:", "@playwright/test": "catalog:", "@tailwindcss/postcss": "catalog:", "@tsconfig/node22": "catalog:", @@ -39,6 +42,7 @@ "@vue/compiler-dom": "catalog:", "@vue/test-utils": "catalog:", "@vue/tsconfig": "catalog:", + "axe-core": "catalog:", "happy-dom": "catalog:", "postcss": "catalog:", "portless": "catalog:", diff --git a/clients/web/apps/dashboard/scripts/run-coverage.mjs b/clients/web/apps/dashboard/scripts/run-coverage.mjs new file mode 100644 index 000000000..45b4a4c06 --- /dev/null +++ b/clients/web/apps/dashboard/scripts/run-coverage.mjs @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { relative } from "node:path"; +import process from "node:process"; + +const rootDir = new URL("..", import.meta.url); +const srcDir = new URL("../src", import.meta.url); + +async function collectSpecFiles(dirUrl) { + const entries = await readdir(dirUrl, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const entryUrl = new URL(entry.name, `${dirUrl.href}${dirUrl.href.endsWith("/") ? "" : "/"}`); + if (entry.isDirectory()) { + return collectSpecFiles(entryUrl); + } + return entry.isFile() && entry.name.endsWith(".spec.ts") ? [entryUrl] : []; + }) + ); + + return files.flat(); +} + +const specFileUrls = await collectSpecFiles(srcDir); +const specFiles = specFileUrls + .map((fileUrl) => relative(rootDir.pathname, fileUrl.pathname)) + .sort((left, right) => left.localeCompare(right)); + +if (specFiles.length === 0) { + console.error("No dashboard unit spec files were found under src/."); + process.exit(1); +} + +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const vitestArgs = [ + "exec", + "vitest", + "--run", + "--environment", + "happy-dom", + "--coverage", + ...specFiles, +]; + +const child = spawn(pnpmCommand, vitestArgs, { + cwd: rootDir, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/clients/web/apps/dashboard/src/App.spec.ts b/clients/web/apps/dashboard/src/App.spec.ts index a4e862602..b9494bffb 100644 --- a/clients/web/apps/dashboard/src/App.spec.ts +++ b/clients/web/apps/dashboard/src/App.spec.ts @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { nextTick, reactive, ref } from "vue"; import { createI18n } from "vue-i18n"; @@ -11,10 +11,12 @@ import type { } from "@/composables/useConfig"; import { i18nConfig } from "@/i18n"; import { createAdminConfigForm } from "@/test/adminConfigFormFactory"; +import { expectNoAxeViolations } from "@/test/runAxe"; const mockedConfigState = vi.hoisted(() => ({ current: null as ReturnType | null, })); +const mountedWrappers: Array> = []; vi.mock("@/composables/useConfig", () => ({ useConfig: () => { @@ -47,6 +49,7 @@ vi.mock("@corvus/ui", async () => { }), Input: defineComponent({ name: "Input", + inheritAttrs: false, props: { modelValue: { type: String, @@ -59,7 +62,7 @@ vi.mock("@corvus/ui", async () => { }, emits: ["update:modelValue"], template: - '', + '', }), }; }); @@ -185,6 +188,58 @@ vi.mock("@/components/memory/CerebroTimelinePanel.vue", async () => { }; }); +vi.mock("@/components/sessions/SessionList.vue", async () => { + const { defineComponent } = await import("vue"); + + return { + default: defineComponent({ + name: "SessionList", + emits: ["select"], + template: ` +
+ +
+ `, + }), + }; +}); + +vi.mock("@/components/sessions/SessionDetail.vue", async () => { + const { defineComponent, ref } = await import("vue"); + + return { + default: defineComponent({ + name: "SessionDetail", + props: { + sessionId: { + type: String, + required: true, + }, + }, + emits: ["close", "view-memory"], + setup(_, { expose }) { + const closeRef = ref(null); + expose({ + focusCloseButton: () => closeRef.value?.focus(), + }); + return { closeRef }; + }, + template: ` +
+ + +
+ `, + }), + }; +}); + function createSectionModule(name: string) { return async () => { const { defineComponent } = await import("vue"); @@ -335,17 +390,26 @@ function createMockConfig( function mountApp(config = createMockConfig()) { mockedConfigState.current = config; const i18n = createI18n(i18nConfig); + const wrapper = mount(App, { + attachTo: document.body, + global: { + plugins: [i18n], + }, + }); + mountedWrappers.push(wrapper); return { config, - wrapper: mount(App, { - global: { - plugins: [i18n], - }, - }), + wrapper, }; } +afterEach(() => { + while (mountedWrappers.length > 0) { + mountedWrappers.pop()?.unmount(); + } +}); + describe("Dashboard App", () => { it("renders auth controls, config sections, and webhook helper state", () => { const { wrapper } = mountApp( @@ -363,6 +427,36 @@ describe("Dashboard App", () => { expect(wrapper.findAll(".onboarding-step")).toHaveLength(4); }); + it("adds a skip link and exposes accessible auth input semantics", () => { + const { wrapper } = mountApp(); + + const skipLink = wrapper.get("a.skip-link"); + const mainContent = wrapper.get("#main-content"); + const authInputs = wrapper.findAll("input"); + const baseUrlInput = authInputs[0]; + const pairingCodeInput = authInputs[1]; + const bearerTokenInput = authInputs[2]; + + expect(skipLink.attributes("href")).toBe("#main-content"); + expect(mainContent.attributes("tabindex")).toBe("-1"); + + expect(baseUrlInput?.attributes("type")).toBe("url"); + expect(baseUrlInput?.attributes("autocomplete")).toBe("url"); + + expect(pairingCodeInput?.attributes("autocomplete")).toBe("one-time-code"); + expect(pairingCodeInput?.attributes("aria-describedby")).toBe("auth-pairing-code-help"); + + expect(bearerTokenInput?.attributes("aria-describedby")).toBe("auth-bearer-token-help"); + expect(bearerTokenInput?.attributes("autocapitalize")).toBe("off"); + expect(wrapper.text()).toContain("password managers or secure vault tools"); + }); + + it("has no obvious axe violations in the dashboard shell", async () => { + const { wrapper } = mountApp(createMockConfig({ isOperatorReady: true })); + + await expectNoAxeViolations(wrapper.element); + }); + it("shows quick-pair progress states and hides auth controls while connecting", async () => { const { wrapper, config } = mountApp(createMockConfig({ quickPairState: "validating" })); @@ -565,4 +659,25 @@ describe("Dashboard App", () => { expect(wrapper.find("[data-testid='cerebro-search']").exists()).toBe(true); expect(wrapper.find("[data-testid='local-memory-explorer']").exists()).toBe(false); }); + + it("moves focus into session detail and restores it to the opener on close", async () => { + const { wrapper } = mountApp(createMockConfig({ isOperatorReady: true })); + + await wrapper.find('[data-testid="nav-sessions"]').trigger("click"); + + const openButton = wrapper.get('[data-testid="view-session-session-42"]'); + await openButton.trigger("click"); + await nextTick(); + + expect(wrapper.find('[data-testid="session-detail"]').exists()).toBe(true); + expect(document.activeElement).toBe(wrapper.get(".session-detail-close").element); + + await wrapper.get(".session-detail-close").trigger("click"); + await nextTick(); + + await new Promise((resolve) => globalThis.requestAnimationFrame(resolve)); + + expect(wrapper.find('[data-testid="session-detail"]').exists()).toBe(false); + expect(document.activeElement).toBe(openButton.element); + }); }); diff --git a/clients/web/apps/dashboard/src/App.vue b/clients/web/apps/dashboard/src/App.vue index 3ce19470a..9cdf2847a 100644 --- a/clients/web/apps/dashboard/src/App.vue +++ b/clients/web/apps/dashboard/src/App.vue @@ -2,7 +2,7 @@ import { trimTrailingSlashes } from "@corvus/shared"; // biome-ignore lint/correctness/noUnusedImports: Used in Vue template. import { Button, Input } from "@corvus/ui"; -import { ref } from "vue"; +import { nextTick, ref } from "vue"; import { useI18n } from "vue-i18n"; // biome-ignore lint/correctness/noUnusedImports: Used in Vue template. import ChatWorkspace from "@/components/chat/ChatWorkspace.vue"; @@ -85,6 +85,14 @@ const dashboardTabIds: Record = { memory: "dashboard-tab-memory", chat: "dashboard-tab-chat", }; +const mainContentRef = ref(null); + +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. +function focusMainContent(): void { + globalThis.requestAnimationFrame(() => { + mainContentRef.value?.focus(); + }); +} function selectDashboardPage(page: DashboardPage): void { currentPage.value = page; @@ -96,6 +104,7 @@ function selectDashboardPage(page: DashboardPage): void { }); } +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. function handleTabKeydown(event: KeyboardEvent, page: DashboardPage): void { const currentIndex = dashboardTabs.indexOf(page); if (currentIndex < 0) { @@ -127,6 +136,21 @@ const sessionStatusFilter = ref<"active" | "ended" | undefined>(undefined); // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. const sessionSort = ref<"last_activity" | "started_at">("last_activity"); const selectedSession = ref(null); +const sessionDetailRef = ref<{ focusCloseButton: () => void } | null>(null); +const selectedSessionTriggerId = ref(null); + +function focusSessionTrigger(sessionId: string | null): void { + if (!sessionId) { + return; + } + + globalThis.requestAnimationFrame(() => { + const trigger = globalThis.document?.querySelector( + `[data-testid="view-session-${sessionId}"]` + ); + trigger?.focus(); + }); +} // Memory view state const memoryCategoryFilter = ref(undefined); @@ -159,8 +183,18 @@ function adminAuthHeaders(): Record { } // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. -function onSelectSession(session: AdminSessionView) { +async function onSelectSession(session: AdminSessionView) { + selectedSessionTriggerId.value = session.id; selectedSession.value = session; + await nextTick(); + sessionDetailRef.value?.focusCloseButton(); +} + +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. +function closeSelectedSession(): void { + const triggerId = selectedSessionTriggerId.value; + selectedSession.value = null; + focusSessionTrigger(triggerId); } // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. @@ -220,7 +254,8 @@ function onLocalExplorerSelectionChange(selection: LocalMemoryExplorerSelection) diff --git a/clients/web/apps/dashboard/src/components/memory/MemoryList.spec.ts b/clients/web/apps/dashboard/src/components/memory/MemoryList.spec.ts index 2c794bd61..841b97f76 100644 --- a/clients/web/apps/dashboard/src/components/memory/MemoryList.spec.ts +++ b/clients/web/apps/dashboard/src/components/memory/MemoryList.spec.ts @@ -4,6 +4,7 @@ import { createI18n } from "vue-i18n"; import MemoryList from "@/components/memory/MemoryList.vue"; import { i18nConfig } from "@/i18n"; +import { expectNoAxeViolations } from "@/test/runAxe"; const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(); @@ -136,6 +137,18 @@ describe("MemoryList", () => { expect(wrapper.text()).toContain("fact-1"); }); + it("applies minimum target classes to compact action controls", async () => { + mockMemoryResponse(sampleEntries); + + const wrapper = mountMemoryList(); + await flushPromises(); + + expect(wrapper.find("button.category-badge").classes()).toContain("touch-target"); + expect(wrapper.find("button.session-link").classes()).toContain("touch-target"); + expect(wrapper.find("button.explore-btn").classes()).toContain("touch-target"); + expect(wrapper.find("button.delete-btn").classes()).toContain("touch-target"); + }); + it("sends DELETE request when deletion is confirmed", async () => { mockMemoryResponse(sampleEntries); @@ -183,6 +196,47 @@ describe("MemoryList", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("traps focus inside the delete confirmation dialog", async () => { + mockMemoryResponse(sampleEntries); + + const wrapper = mountMemoryList(); + await flushPromises(); + + await wrapper.find('[aria-label="Delete fact-1"]').trigger("click"); + await flushPromises(); + + const dialog = wrapper.get(".confirm-dialog"); + const confirmButton = wrapper.get(".confirm-yes").element as HTMLButtonElement; + const cancelButton = wrapper.get(".confirm-no").element as HTMLButtonElement; + + expect(document.activeElement).toBe(confirmButton); + + confirmButton.focus(); + await dialog.trigger("keydown", { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(cancelButton); + + cancelButton.focus(); + await dialog.trigger("keydown", { key: "Tab" }); + expect(document.activeElement).toBe(confirmButton); + }); + + it("has no obvious axe violations for the delete confirmation dialog", async () => { + mockMemoryResponse(sampleEntries); + + const wrapper = mountMemoryList(); + await flushPromises(); + + await wrapper.find('[aria-label="Delete fact-1"]').trigger("click"); + await flushPromises(); + + const dialog = wrapper.get(".confirm-dialog"); + await expectNoAxeViolations(dialog.element, { + rules: { + region: { enabled: false }, + }, + }); + }); + it.each([ ["sessionIdFilter", "session-42", "session_id"], ["categoryFilter", "Core", "category"], diff --git a/clients/web/apps/dashboard/src/components/memory/MemoryList.vue b/clients/web/apps/dashboard/src/components/memory/MemoryList.vue index c634828e4..e5e26cb74 100644 --- a/clients/web/apps/dashboard/src/components/memory/MemoryList.vue +++ b/clients/web/apps/dashboard/src/components/memory/MemoryList.vue @@ -25,8 +25,21 @@ const page = ref(1); const perPage = ref(25); const confirmingDelete = ref(null); const confirmBtnRef = ref(null); +const confirmDialogRef = ref(null); const restoreFocusTarget = ref(null); +function getDialogFocusableElements(): HTMLElement[] { + if (!confirmDialogRef.value) { + return []; + } + + return Array.from( + confirmDialogRef.value.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ); +} + async function load() { const params: MemoryListParams = { category: props.categoryFilter, @@ -62,11 +75,48 @@ function closeDeleteDialog() { nextTick(() => restoreFocusTarget.value?.focus()); } -// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. function cancelDelete() { closeDeleteDialog(); } +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. +function onDeleteDialogKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + cancelDelete(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusableElements = getDialogFocusableElements(); + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + if (!firstElement || !lastElement) { + return; + } + + const activeElement = globalThis.document?.activeElement; + if (event.shiftKey) { + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } +} + // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. async function confirmDelete() { if (!confirmingDelete.value) return; @@ -150,8 +200,8 @@ onMounted(() => load()); > {{ entry.key }} - @@ -160,7 +210,7 @@ onMounted(() => load());