From 3c91251904b4cf88a4c9736bd44d48d8ab0a0939 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 17 Jul 2026 14:36:06 -0400 Subject: [PATCH 1/3] th-changesets-polyglot: drive lockstep polyglot publishing via Changesets Wire the Changesets release to publish ALL smooth-operator-core artifacts (npm + crates.io + NuGet + PyPI + Go tag) at one lockstep version, instead of npm-only + separate manual publish-*.yml workflows. - scripts/sync-versions.mjs: propagate the canonical npm version (typescript/core/package.json) to the Rust crate Cargo.toml, .NET csproj , Python pyproject, and go/version.go. Fails loudly if any version anchor is missing so a partial sync aborts the release. - scripts/ci-version.mjs: `changeset version` + `version:sync` so the version PR carries every polyglot bump. - scripts/ci-publish.mjs: idempotent per-registry orchestrator. Queries each registry and skips already-published versions; supports DRY_RUN (no tokens, no pushes); one language failing doesn't skip the others but still exits non-zero. Belt-and-suspenders: --skip-duplicate (NuGet), --check-url (PyPI). - release.yml: add the polyglot toolchains (dotnet/uv/rust) + the publish secret env (same names as the manual workflows), and wire version: pnpm ci:version + publish: pnpm ci:publish. - publish-*.yml: kept as manual fallbacks, headers noted accordingly. Nothing is published by this PR. Verified via a local DRY_RUN of ci-publish (npm skipped as already-published; crates/NuGet/PyPI/Go pack+validate to would-publish) and by running sync-versions against the tree and reverting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/polyglot-lockstep-publish.md | 5 + .github/workflows/publish-crate.yml | 4 +- .github/workflows/publish-npm.yml | 4 + .github/workflows/publish-nuget.yml | 4 +- .github/workflows/publish-pypi.yml | 4 +- .github/workflows/release.yml | 30 +++ package.json | 4 +- scripts/ci-publish.mjs | 267 ++++++++++++++++++++++++ scripts/ci-version.mjs | 27 +++ scripts/sync-versions.mjs | 102 +++++++++ 10 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 .changeset/polyglot-lockstep-publish.md create mode 100644 scripts/ci-publish.mjs create mode 100644 scripts/ci-version.mjs create mode 100644 scripts/sync-versions.mjs diff --git a/.changeset/polyglot-lockstep-publish.md b/.changeset/polyglot-lockstep-publish.md new file mode 100644 index 0000000..7fed614 --- /dev/null +++ b/.changeset/polyglot-lockstep-publish.md @@ -0,0 +1,5 @@ +--- +'@smooai/smooth-operator-core': patch +--- + +Release infra: Changesets now drives lockstep publishing of every polyglot artifact (npm + crates.io + NuGet + PyPI + Go tag) from a single canonical version. Adds `scripts/sync-versions.mjs` (propagates the npm version to Rust/.NET/Python/Go manifests) and `scripts/ci-publish.mjs` (idempotent, skip-if-already-published, DRY_RUN) wired into `release.yml`. The per-language `publish-*.yml` workflows remain as manual fallbacks. diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index 1aa3caf..7d3998f 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -1,7 +1,9 @@ # Publish `smooai-smooth-operator-core` to crates.io. # # ───────────────────────────────────────────────────────────────────────────── -# THIS WORKFLOW IS THE ONLY PLACE THIS CRATE IS PUBLISHED. +# MANUAL FALLBACK. The Changesets release (release.yml → `pnpm ci:publish`) now +# drives the normal, lockstep publish of this crate. Keep this workflow for +# out-of-band / recovery publishes; both paths are idempotent (skip-if-exists). # # • Publishing to crates.io is IRREVERSIBLE — there is no delete, only # `cargo yank` (which hides a version but cannot reuse the version number). diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index c569d4b..3988ef6 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,6 +1,10 @@ # Publish the smooth-operator-core TypeScript engine to npm. # # ───────────────────────────────────────────────────────────────────────────── +# MANUAL FALLBACK. The Changesets release (release.yml → `pnpm ci:publish`) now +# drives the normal, lockstep publish. Keep this workflow for out-of-band / +# recovery publishes; both paths are idempotent (skip-if-exists). +# # Package: # • @smooai/smooth-operator-core (typescript/core/) — the native, in-process # TypeScript agent engine. The sibling of the Rust reference engine, the C# diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 195629d..b487c27 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -1,7 +1,9 @@ # Publish the C# agent-engine core to NuGet. # # ───────────────────────────────────────────────────────────────────────────── -# THIS WORKFLOW IS THE ONLY PLACE THIS PACKAGE IS PUBLISHED. +# MANUAL FALLBACK. The Changesets release (release.yml → `pnpm ci:publish`) now +# drives the normal, lockstep publish. Keep this workflow for out-of-band / +# recovery publishes; both paths are idempotent (--skip-duplicate). # # Package: SmooAI.SmoothOperator.Core (dotnet/core/src) — the native C# engine. # diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 89a6ab2..da92753 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,7 +1,9 @@ # Publish the Python agent-engine core to PyPI. # # ───────────────────────────────────────────────────────────────────────────── -# THIS WORKFLOW IS THE ONLY PLACE THIS PACKAGE IS PUBLISHED. +# MANUAL FALLBACK. The Changesets release (release.yml → `pnpm ci:publish`) now +# drives the normal, lockstep publish. Keep this workflow for out-of-band / +# recovery publishes; both paths are idempotent (--check-url). # # Package: smooai-smooth-operator-core (python/core) — the native Python engine. # diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e78d35..6760bff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,17 +41,47 @@ jobs: - name: Install dependencies run: pnpm install + # ── Polyglot toolchains ────────────────────────────────────────── + # ci:version (version step) syncs every manifest; ci:publish (publish + # step) packs + pushes to crates.io / NuGet / PyPI / Go. Both run + # inside the changesets/action step below, so every toolchain must be + # present before it. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: '0.7.8' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - name: Version Update 🦋 id: changesets uses: changesets/action@v1 with: + version: pnpm ci:version commit: '🦋 New version release' title: '🦋 New version release' publish: pnpm ci:publish createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} + # Publish tokens for ci:publish — org-level secrets, same names + # the manual publish-*.yml workflows use. NODE_AUTH_TOKEN: ${{ secrets.SMOOAI_NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' + CARGO_REGISTRY_TOKEN: ${{ secrets.SMOOAI_CARGO_REGISTRY_TOKEN }} + NUGET_API_KEY: ${{ secrets.SMOOAI_NUGET_API_KEY }} + UV_PUBLISH_TOKEN: ${{ secrets.SMOOAI_PYPI_TOKEN }} - name: Auto-Merge Changeset PR run: | diff --git a/package.json b/package.json index 0bb4478..7d3cb39 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "rust:clippy": "cd rust && cargo clippy --workspace --all-targets -- -D warnings", "rust:test": "cd rust && cargo test --workspace", "changeset": "changeset", - "ci:publish": "pnpm --filter @smooai/smooth-operator-core build && changeset publish" + "version:sync": "node scripts/sync-versions.mjs", + "ci:version": "node scripts/ci-version.mjs", + "ci:publish": "node scripts/ci-publish.mjs" }, "devDependencies": { "@changesets/cli": "^2.27.10" diff --git a/scripts/ci-publish.mjs b/scripts/ci-publish.mjs new file mode 100644 index 0000000..8196d46 --- /dev/null +++ b/scripts/ci-publish.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node +/** + * CI publish orchestrator — runs after the Changeset version PR merges + * (wired as the `publish:` input of changesets/action in release.yml). + * + * Publishes the ONE lockstep version to every polyglot registry: + * + * • npm — @smooai/smooth-operator-core (typescript/core) + * • crates.io— smooai-smooth-operator-core (rust/smooth-operator-core) + * • NuGet — SmooAI.SmoothOperator.Core (dotnet/core) + * • PyPI — smooai-smooth-operator-core (python/core) + * • Go — git tag go/vX.Y.Z (go/ — "publish" == tag) + * + * ── SAFETY (these are IRREVERSIBLE registries — a NuGet/PyPI/crates version can + * never be deleted or reused) ──────────────────────────────────────────────── + * • IDEMPOTENT: every registry is queried for the target version first and + * SKIPPED if already present, so a re-run never double-publishes and never + * hard-fails on an existing version. Belt: --skip-duplicate (NuGet) / + * --check-url (PyPI) as a second line of defence. + * • DRY_RUN (env DRY_RUN=true or `--dry-run`): does the existence checks and + * packs/validates, but PUSHES NOTHING and needs NO tokens. This is what you + * run to prove the logic locally. + * • Isolation: a failure in one language is recorded and the others still run, + * but ANY failure makes the whole run exit non-zero — a partial release + * surfaces loudly and the idempotent re-run picks up where it left off. + * + * sync-versions runs FIRST (real runs only) so every manifest carries the + * canonical version before we pack. In the normal flow the merged version PR + * already synced them; this is belt-and-suspenders. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import https from "node:https"; +import process from "node:process"; + +const root = process.cwd(); +const DRY_RUN = process.env.DRY_RUN === "true" || process.argv.includes("--dry-run"); + +const version = JSON.parse(readFileSync(resolve(root, "typescript/core/package.json"), "utf8")).version; +if (!version) { + console.error("Unable to read version from typescript/core/package.json"); + process.exit(1); +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +function run(cmd, args, opts = {}) { + console.log(` > ${cmd} ${args.join(" ")}`); + execFileSync(cmd, args, { stdio: "inherit", cwd: root, ...opts }); +} + +function hasTool(name) { + try { + execFileSync("sh", ["-c", `command -v ${name}`], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function httpGet(url) { + return new Promise((res, rej) => { + https + .get(url, { headers: { "user-agent": "smooth-operator-core-ci-publish" } }, (r) => { + let body = ""; + r.setEncoding("utf8"); + r.on("data", (c) => (body += c)); + r.on("end", () => res({ statusCode: r.statusCode, body })); + }) + .on("error", rej); + }); +} + +// crates.io sparse-index path layout (mirrors smooth/scripts/ci-publish.mjs). +function sparsePath(crate) { + if (crate.length === 1) return `1/${crate}`; + if (crate.length === 2) return `2/${crate}`; + if (crate.length === 3) return `3/${crate[0]}/${crate}`; + return `${crate.slice(0, 2)}/${crate.slice(2, 4)}/${crate}`; +} + +// ── per-registry existence checks (all pure HTTP GET — no auth) ─────────────── + +async function npmHasVersion(name, ver) { + const { statusCode, body } = await httpGet(`https://registry.npmjs.org/${name.replace("/", "%2F")}`); + if (statusCode === 404) return false; + if (statusCode !== 200) throw new Error(`npm registry returned ${statusCode} for ${name}`); + return Boolean(JSON.parse(body).versions?.[ver]); +} + +async function cratesHasVersion(crate, ver) { + const { statusCode, body } = await httpGet(`https://index.crates.io/${sparsePath(crate)}`); + if (statusCode === 404) return false; + if (statusCode !== 200) throw new Error(`crates.io index returned ${statusCode} for ${crate}`); + return body + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .some((line) => { + try { + return JSON.parse(line).vers === ver; + } catch { + return false; + } + }); +} + +async function nugetHasVersion(id, ver) { + const { statusCode, body } = await httpGet(`https://api.nuget.org/v3-flatcontainer/${id.toLowerCase()}/index.json`); + if (statusCode === 404) return false; + if (statusCode !== 200) throw new Error(`NuGet flat-container returned ${statusCode} for ${id}`); + return (JSON.parse(body).versions ?? []).includes(ver); +} + +async function pypiHasVersion(name, ver) { + const { statusCode, body } = await httpGet(`https://pypi.org/pypi/${name}/json`); + if (statusCode === 404) return false; + if (statusCode !== 200) throw new Error(`PyPI returned ${statusCode} for ${name}`); + return Object.prototype.hasOwnProperty.call(JSON.parse(body).releases ?? {}, ver); +} + +function gitTagExists(tag) { + // Local tag OR remote tag counts as "already published". + const local = execFileSync("git", ["tag", "-l", tag], { cwd: root }).toString().trim(); + if (local) return true; + const remote = execFileSync("git", ["ls-remote", "--tags", "origin", tag], { cwd: root }).toString().trim(); + return Boolean(remote); +} + +function requireEnv(name) { + if (!process.env[name]) throw new Error(`${name} is not set (required to publish for real)`); + return process.env[name]; +} + +// ── per-registry publishers ─────────────────────────────────────────────────── +// Each returns a status string. Existence is checked by the caller. + +const registries = [ + { + name: "npm", + artifact: `@smooai/smooth-operator-core@${version}`, + exists: () => npmHasVersion("@smooai/smooth-operator-core", version), + tool: "pnpm", + publish(dry) { + run("pnpm", ["--filter", "@smooai/smooth-operator-core", "build"]); + const flags = ["--filter", "@smooai/smooth-operator-core", "publish", "--no-git-checks", "--access", "public"]; + if (dry) { + run("pnpm", [...flags, "--dry-run"]); + return; + } + requireEnv("NODE_AUTH_TOKEN"); + run("pnpm", flags, { env: { ...process.env, NPM_CONFIG_PROVENANCE: "true" } }); + }, + }, + { + name: "crates.io", + artifact: `smooai-smooth-operator-core@${version}`, + exists: () => cratesHasVersion("smooai-smooth-operator-core", version), + tool: "cargo", + publish(dry) { + if (dry) { + // Package the tarball without a full compile — proves the crate + // is packable without the multi-minute verify build. + run("cargo", ["package", "-p", "smooai-smooth-operator-core", "--no-verify", "--allow-dirty"], { cwd: resolve(root, "rust") }); + return; + } + requireEnv("CARGO_REGISTRY_TOKEN"); + run("cargo", ["publish", "-p", "smooai-smooth-operator-core", "--locked"], { cwd: resolve(root, "rust") }); + }, + }, + { + name: "NuGet", + artifact: `SmooAI.SmoothOperator.Core@${version}`, + exists: () => nugetHasVersion("SmooAI.SmoothOperator.Core", version), + tool: "dotnet", + publish(dry) { + run("dotnet", ["pack", "dotnet/core/src/SmooAI.SmoothOperator.Core.csproj", "-c", "Release", "-o", "dist"]); + if (!existsSync(resolve(root, "dist"))) throw new Error("dotnet pack produced no dist/ output"); + if (dry) return; + const apiKey = requireEnv("NUGET_API_KEY"); + run("dotnet", ["nuget", "push", "dist/*.nupkg", "--api-key", apiKey, "--source", "https://api.nuget.org/v3/index.json", "--skip-duplicate"]); + }, + }, + { + name: "PyPI", + artifact: `smooai-smooth-operator-core@${version}`, + exists: () => pypiHasVersion("smooai-smooth-operator-core", version), + tool: "uv", + publish(dry) { + const cwd = resolve(root, "python/core"); + run("uv", ["build"], { cwd }); + if (dry) return; + requireEnv("UV_PUBLISH_TOKEN"); + // --check-url makes a re-run of an already-published version a no-op. + run("uv", ["publish", "--check-url", "https://pypi.org/simple/"], { cwd }); + }, + }, + { + name: "Go (git tag)", + artifact: `go/v${version}`, + exists: async () => gitTagExists(`go/v${version}`), + tool: "git", + publish(dry) { + const tag = `go/v${version}`; + if (dry) { + console.log(` (dry-run) would create + push tag ${tag} at HEAD`); + return; + } + run("git", ["tag", tag]); + run("git", ["push", "origin", tag]); + }, + }, +]; + +// ── orchestrate ─────────────────────────────────────────────────────────────── + +(async () => { + console.log(`smooth-operator-core release @ ${version}${DRY_RUN ? " (DRY RUN — nothing is pushed)" : ""}\n`); + + if (!DRY_RUN) { + console.log("Syncing manifests to canonical version first…"); + run("node", ["scripts/sync-versions.mjs"]); + console.log(""); + } + + const results = []; + + for (const reg of registries) { + console.log(`── ${reg.name}: ${reg.artifact}`); + try { + if (await reg.exists()) { + console.log(` [skip] already published`); + results.push({ name: reg.name, status: "skipped" }); + continue; + } + + if (DRY_RUN && !hasTool(reg.tool)) { + console.log(` [dry-run] ${reg.tool} not installed — skipping pack (would publish ${reg.artifact})`); + results.push({ name: reg.name, status: "would-publish (pack skipped: no toolchain)" }); + continue; + } + + console.log(` ${DRY_RUN ? "[dry-run] pack/validate — would publish" : "[publish]"} ${reg.artifact}`); + reg.publish(DRY_RUN); + results.push({ name: reg.name, status: DRY_RUN ? "would-publish" : "published" }); + } catch (err) { + // Isolate: record and keep going so one broken language doesn't mask + // the state of the others. The non-zero exit below still surfaces it. + console.error(` [FAIL] ${reg.name}: ${err.message}`); + results.push({ name: reg.name, status: "FAILED", error: err.message }); + } + } + + console.log(`\n── Summary (@ ${version}${DRY_RUN ? ", dry run" : ""}) ──`); + for (const r of results) console.log(` ${r.name.padEnd(14)} ${r.status}`); + + const failures = results.filter((r) => r.status === "FAILED"); + if (failures.length) { + console.error(`\n${failures.length} registr${failures.length === 1 ? "y" : "ies"} failed — see above.`); + process.exit(1); + } + console.log(`\nDone.`); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/ci-version.mjs b/scripts/ci-version.mjs new file mode 100644 index 0000000..58fd064 --- /dev/null +++ b/scripts/ci-version.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * CI version script — runs during Changeset version-PR creation + * (wired as the `version:` input of changesets/action in release.yml). + * + * 1. `changeset version` — consume changeset files, bump + * typescript/core/package.json (the canonical npm version). + * 2. `version:sync` — propagate that version to the Rust/.NET/Python/Go + * manifests so the SAME version PR carries every polyglot bump. + * + * Without step 2 in the version step, the sibling manifests would only be + * rewritten at publish time — after the version PR already merged with stale + * Cargo.toml / csproj / pyproject / version.go. Doing it here keeps the repo + * self-consistent at every commit on main. + */ +import { execSync } from "node:child_process"; +import process from "node:process"; + +const root = process.cwd(); + +function run(cmd) { + console.log(`\n> ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd: root }); +} + +run("pnpm changeset version"); +run("pnpm version:sync"); diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs new file mode 100644 index 0000000..1465c42 --- /dev/null +++ b/scripts/sync-versions.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Sync the canonical version → every publishable polyglot manifest. + * + * The single source of truth is the npm package `@smooai/smooth-operator-core` + * (typescript/core/package.json) — the one package Changesets version-bumps. + * This script propagates that version, in lockstep, to the sibling engines so a + * changeset release ships npm + crates.io + NuGet + PyPI + Go all at one number: + * + * 1. Rust — rust/smooth-operator-core/Cargo.toml [package] version + * 2. .NET — dotnet/core/src/SmooAI.SmoothOperator.Core.csproj + * 3. Python — python/core/pyproject.toml [project] version + * 4. Go — go/version.go const Version (the anchor + * scripts read for the `go/vX.Y.Z` publish tag; see ci-publish.mjs) + * + * NOT touched, on purpose: + * • Cargo.lock — gitignored in this repo (see .gitignore), so there is nothing + * tracked to rewrite. `cargo publish` regenerates it. + * • rust/smooth-operator-temporal — `publish = false`, path-deps core with no + * version requirement; it never reaches a registry so its version is inert. + * + * FAILS LOUDLY: if any expected version anchor is missing we throw rather than + * silently ship a mismatched set — a partial sync must abort the release, never + * publish half the languages at the new version and half at the old one. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; + +const root = process.cwd(); + +// Canonical version = the npm package Changesets bumps. +const canonicalPkgPath = resolve(root, "typescript/core/package.json"); +const canonicalPkg = JSON.parse(readFileSync(canonicalPkgPath, "utf8")); +const version = canonicalPkg.version; + +if (!version) { + console.error("Unable to read version from typescript/core/package.json"); + process.exit(1); +} +if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error(`Refusing to sync a non-semver version: "${version}"`); + process.exit(1); +} + +/** + * Each anchor pins a version inside exactly one manifest. `pattern` MUST have + * two capture groups wrapping the version text so we can splice the new value + * in without disturbing surrounding formatting. + */ +const anchors = [ + { + label: "Rust crate (Cargo.toml [package] version)", + path: "rust/smooth-operator-core/Cargo.toml", + pattern: /(name = "smooai-smooth-operator-core"\nversion = ")[^"]+(")/, + }, + { + label: ".NET package (csproj )", + path: "dotnet/core/src/SmooAI.SmoothOperator.Core.csproj", + pattern: /()[^<]+(<\/Version>)/, + }, + { + label: "Python package (pyproject [project] version)", + path: "python/core/pyproject.toml", + pattern: /(name = "smooai-smooth-operator-core"\nversion = ")[^"]+(")/, + }, + { + label: "Go module (version.go const Version)", + path: "go/version.go", + pattern: /(const Version = ")[^"]+(")/, + }, +]; + +let touched = 0; + +for (const { label, path, pattern } of anchors) { + const absolutePath = resolve(root, path); + let content; + try { + content = readFileSync(absolutePath, "utf8"); + } catch (error) { + if (error && error.code === "ENOENT") { + throw new Error(`Version anchor file missing: ${path} (${label})`); + } + throw error; + } + + if (!pattern.test(content)) { + throw new Error(`Version anchor not found in ${path} (${label}) — refusing partial sync`); + } + + const next = content.replace(pattern, `$1${version}$2`); + if (next !== content) { + writeFileSync(absolutePath, next); + touched += 1; + console.log(`Updated ${label} → ${version} (${path})`); + } else { + console.log(`Already ${version}: ${label} (${path})`); + } +} + +console.log(`\nSynced version ${version} across ${anchors.length} manifest(s); ${touched} changed.`); From 6b87742d109f0aec7142e46ee57109d5a1d844c4 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 17 Jul 2026 14:41:41 -0400 Subject: [PATCH 2/3] =?UTF-8?q?th-changesets-polyglot:=20align=20all=20man?= =?UTF-8?q?ifests=20to=20lockstep=20base=201.6.0=20(+=20changeset=20?= =?UTF-8?q?=E2=86=92=201.7.0=20first=20lockstep=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/lockstep-1-7-0.md | 10 ++++ go/version.go | 2 +- python/core/pyproject.toml | 2 +- rust/smooth-operator-core/Cargo.toml | 2 +- typescript/core/package.json | 84 ++++++++++++++-------------- 5 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 .changeset/lockstep-1-7-0.md diff --git a/.changeset/lockstep-1-7-0.md b/.changeset/lockstep-1-7-0.md new file mode 100644 index 0000000..cd5c886 --- /dev/null +++ b/.changeset/lockstep-1-7-0.md @@ -0,0 +1,10 @@ +--- +"@smooai/smooth-operator-core": minor +--- + +First lockstep polyglot release. Changesets now drives publishing for every +language artifact (npm + crates.io + NuGet + PyPI + Go tag) at a single shared +version via `scripts/ci-publish.mjs`, with `scripts/sync-versions.mjs` +propagating the Changeset version to all manifests. This aligns the previously +divergent per-language versions (npm 0.22, Rust 0.16, .NET 1.6, Python 1.3) onto +one lockstep line at 1.7.0 — no registry downgrades. diff --git a/go/version.go b/go/version.go index 1018e12..a3b76cb 100644 --- a/go/version.go +++ b/go/version.go @@ -5,4 +5,4 @@ package e2e // language artifacts. The real Go "publish" is a git tag (go/v); this // constant is the anchor that scripts/sync-versions.mjs keeps in sync with the // canonical npm version on every changeset release. -const Version = "1.1.0" +const Version = "1.6.0" diff --git a/python/core/pyproject.toml b/python/core/pyproject.toml index 742ed51..49f9e84 100644 --- a/python/core/pyproject.toml +++ b/python/core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "smooai-smooth-operator-core" -version = "1.3.2" +version = "1.6.0" description = "Native Python implementation of the smooth-operator agent engine — an in-process, OpenAI-compatible agentic tool-calling loop with knowledge grounding. The Python sibling of the Rust reference engine and the C# core." readme = "README.md" license = { text = "MIT" } diff --git a/rust/smooth-operator-core/Cargo.toml b/rust/smooth-operator-core/Cargo.toml index 9b7e60d..0a0a026 100644 --- a/rust/smooth-operator-core/Cargo.toml +++ b/rust/smooth-operator-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smooai-smooth-operator-core" -version = "0.16.2" +version = "1.6.0" edition = "2021" license = "MIT" repository = "https://github.com/SmooAI/smooth-operator-core" diff --git a/typescript/core/package.json b/typescript/core/package.json index 41bd40b..f28aa43 100644 --- a/typescript/core/package.json +++ b/typescript/core/package.json @@ -1,44 +1,44 @@ { - "name": "@smooai/smooth-operator-core", - "version": "0.22.0", - "description": "Native TypeScript implementation of the smooth-operator agent engine — an in-process, OpenAI-compatible agentic tool-calling loop with knowledge grounding. The TypeScript sibling of the Rust reference engine, the C# core, and the Python core.", - "type": "module", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/SmooAI/smooth-operator-core.git", - "directory": "typescript/core" - }, - "homepage": "https://github.com/SmooAI/smooth-operator-core/tree/main/typescript/core", - "sideEffects": false, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./extension": { - "types": "./dist/extension/index.d.ts", - "import": "./dist/extension/index.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -p tsconfig.build.json", - "prepack": "pnpm run build", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" - }, - "dependencies": { - "openai": "^4.77.0", - "smol-toml": "^1.7.0" - }, - "devDependencies": { - "@types/node": "^25.9.2", - "typescript": "^5.7.3", - "vitest": "^2.1.8" - } + "name": "@smooai/smooth-operator-core", + "version": "1.6.0", + "description": "Native TypeScript implementation of the smooth-operator agent engine — an in-process, OpenAI-compatible agentic tool-calling loop with knowledge grounding. The TypeScript sibling of the Rust reference engine, the C# core, and the Python core.", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/SmooAI/smooth-operator-core.git", + "directory": "typescript/core" + }, + "homepage": "https://github.com/SmooAI/smooth-operator-core/tree/main/typescript/core", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./extension": { + "types": "./dist/extension/index.d.ts", + "import": "./dist/extension/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepack": "pnpm run build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "openai": "^4.77.0", + "smol-toml": "^1.7.0" + }, + "devDependencies": { + "@types/node": "^25.9.2", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } } From 01f133147b1f181bd6bd91d69a16ab9f1517e1a1 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 17 Jul 2026 14:38:02 -0400 Subject: [PATCH 3/3] docs: rewrite root + per-language READMEs as storytelling registry pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elevate every README (root + Rust/TS/Python/Go/.NET package pages) from a feature list into a narrative: hook → one engine in five languages → observe→think→act → the permission gate + deny-policy that makes an agent safe to point at production → build → get started. - Each package page leads with an agent+tool quickstart in its own idiom (mock scripted to call the tool, then answer), verified against the real API. - Adds the headline permission system + deny-policy (AutoMode, circuit-breakers, declarative TOML rules + semantic predicates) to every features list and gives it a dedicated safety-story section with a language-native example (with_deny_policy in Rust, denyPolicy/permissionMode in TS/Py, WithDenyPolicy in Go/C#). - Refreshes the root polyglot table (language → package → registry), fixes the stale "Planned" statuses (TS/Python/.NET are at parity now) and inconsistent test-count claims. Docs only — no code changes. Registry readme-pointers already wired (Cargo readme, pyproject readme, csproj PackageReadmeFile+packed README; npm ships README by default). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/readme-story-permissions.md | 20 +++++++ README.md | 75 +++++++++++++++++------ dotnet/core/README.md | 63 +++++++++++++++++--- go/core/README.md | 75 ++++++++++++++++++++--- python/core/README.md | 71 +++++++++++++++++++--- rust/smooth-operator-core/README.md | 82 +++++++++++++++++++++++--- typescript/core/README.md | 70 ++++++++++++++++++---- 7 files changed, 397 insertions(+), 59 deletions(-) create mode 100644 .changeset/readme-story-permissions.md diff --git a/.changeset/readme-story-permissions.md b/.changeset/readme-story-permissions.md new file mode 100644 index 0000000..095f736 --- /dev/null +++ b/.changeset/readme-story-permissions.md @@ -0,0 +1,20 @@ +--- +"@smooai/smooth-operator-core": patch +--- + +docs: rewrite the root + per-language package READMEs as registry landing pages that tell a story + +Every README (root and the Rust / TypeScript / Python / Go / .NET package pages) +now opens with a hook and a narrative arc — problem → one engine in five +languages → observe→think→act → the permission gate + deny-policy that makes an +agent safe to point at production → build → get started. Each package page leads +with a tight agent-plus-tool quickstart in its own idiom (the mock scripted to +call the tool, then answer) and a permissions/deny-policy example using that +language's real API (`with_deny_policy` in Rust, `denyPolicy`/`permissionMode` +options in TS/Py, `WithDenyPolicy` in Go/C#). + +Adds the headline permission system + deny-policy (AutoMode ask / accept-edits / +deny-unmatched / bypass, circuit-breakers, declarative TOML rules + semantic +predicates) to the feature surface, refreshes the polyglot table +(language → package → registry), and fixes stale test-count claims. Docs only — +no code changes. diff --git a/README.md b/README.md index 886c709..9b28599 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

Rust reference implementation - 337 tests passing + tests passing

@@ -19,13 +19,15 @@ --- -> The agent runtime behind the [smooth-operator](https://github.com/SmooAI/smooth-operator) service and [lom.smoo.ai](https://lom.smoo.ai). Agents, workflows, tools, checkpointing, memory, human-in-the-loop, and per-model cost budgets — as a single embeddable Rust crate. It's the engine, not a notebook demo. +> ### The agent brain you can point at production — because you decide what it must never do. +> +> One observe→think→act engine — typed tools, streaming, checkpointing, memory, cost budgets, and a permission gate with hard lines the model can't cross — native in **Rust, TypeScript, Python, Go, and C#**. -`smooai-smooth-operator-core` is the agent runtime that powers the [**smooth-operator**](https://github.com/SmooAI/smooth-operator) service and [**lom.smoo.ai**](https://lom.smoo.ai). It gives you the moving parts of a serious agent framework — an observe→think→act loop, a typed tool system, a graph workflow engine, pluggable checkpoint stores, memory, RAG, human-in-the-loop gates, and per-model cost budgets — as a single, embeddable Rust crate. +Most agent frameworks hand the model a pile of tools and hope for the best. `smooth-operator-core` gives you the whole loop **and the brakes**: a typed tool system with pre/post hooks, human-in-the-loop gates, per-model cost budgets — and a **deny-policy** that lets you draw lines the model can never cross, not even in bypass mode. *No prod AWS profile. No writes to the DB writer. No `rm -rf /`.* Declared once, enforced on every tool call. -Inspired by LangGraph, CrewAI, and Agno, with one hard difference: **it's the engine, not a notebook demo.** Every surface is covered by **337 fast, offline unit tests** built on a deterministic `MockLlmClient`, so the loop is verified — not vibe-coded. +It's the runtime that powers the [**smooth-operator**](https://github.com/SmooAI/smooth-operator) service and [**lom.smoo.ai**](https://lom.smoo.ai) — not a notebook demo. Inspired by LangGraph, CrewAI, and Agno, with one hard difference: every surface is covered by **hundreds of fast, offline unit tests** built on a deterministic `MockLlmClient`, so the loop is verified — not vibe-coded. And it's the **same engine in five languages** — write your agent where your stack already lives. -> The Rust implementation is the source of truth. TypeScript, Go, C#/.NET, and Python bindings mirror its surface (protocol-first; see [Repository layout](#repository-layout)). +> The Rust implementation is the source of truth. The TypeScript, Python, Go, and C#/.NET ports mirror its surface at parity (protocol-first; see [Repository layout](#repository-layout)). --- @@ -166,6 +168,7 @@ let agent = Agent::new(config, registry).with_checkpoint_store(checkpoints); | --- | --- | | An agent loop you can **trust** | observe→think→act with iteration caps, parallel tool calls, and a typed `AgentEvent` stream | | **Typed tools** with guardrails | `Tool` trait + `ToolRegistry`, with pre/post hooks for surveillance, secret detection, prompt-injection guards | +| **Deny what must never run** | `PermissionHook` gate (`AutoMode`: ask / accept-edits / deny-unmatched / bypass) + hard circuit-breakers + a consumer `DenyPolicy` (declarative TOML rules + semantic predicates) | | **Stateful graphs** (a LangGraph analog) | `Workflow` / `WorkflowBuilder` with conditional edges and typed state | | **Resume after a crash** | `CheckpointStore`: in-memory, file, SQLite, or Postgres | | **RAG + memory** | `KnowledgeBase` / `Memory` traits (with in-memory impls) as clean seams | @@ -178,6 +181,44 @@ It's the runtime the smooth-operator service actually ships on — not a referen --- +## Permissions & deny-policy — draw lines the agent can't cross + +Here's the thing that makes an agent safe to point at real infrastructure: **you** decide what it can never do, and no prompt, jailbreak, or model mistake can talk it out of that. + +Every tool call passes through a gate before it runs. `AutoMode` sets the baseline posture — read-only calls **allow**, mutating calls **ask**, dangerous calls **deny** — and hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains) fire in *every* mode, `Bypass` included. On top of that you attach a **`DenyPolicy`**: declarative TOML rules for the lines you can name, plus semantic predicates for the ones you can't. + +```rust +use std::sync::Arc; +use smooth_operator_core::{Agent, AutoMode, DenyPolicy, DenyPredicate, DenyReason, ToolCall}; + +// Predicate: the checks strings can't express — is this AWS call the *prod account*? +// Is this DB connection the *writer* endpoint? Return Some(reason) to deny. +struct DenyDbWriter; +impl DenyPredicate for DenyDbWriter { + fn evaluate(&self, call: &ToolCall) -> Option { + (call.name == "db_query" && call.arguments.to_string().contains("writer")) + .then(|| DenyReason::new("DB writer is off-limits — reads go to the replica")) + } +} + +// Declarative rules: never the prod AWS profile, never a prod host. +let policy = DenyPolicy::from_toml(r#" + schema_version = 1 + [bash] + deny_patterns = ["aws * --profile prod"] + [network] + deny_hosts = ["*.prod.internal"] +"#)?.with_predicate(Arc::new(DenyDbWriter)); + +let agent = Agent::new(config, registry) + .with_permission_mode(AutoMode::Ask) + .with_deny_policy(Arc::new(policy)); +``` + +A deny-policy match is a **hard deny of circuit-breaker tier** — no stored grant waives it, no mode downgrades it. That's the difference between "we asked the model nicely" and "it structurally cannot." And it's identical across all five languages. + +--- + ## Architecture ### The agent loop @@ -237,7 +278,7 @@ The service is thin: it terminates the WebSocket protocol and hands turns to the ## Test-driven by default — verified, not vibe-coded -This is the part we care about most. The engine ships **408 unit tests** that run in **seconds, fully offline**, because every LLM call goes through an `LlmProvider` seam that tests satisfy with `MockLlmClient`: +This is the part we care about most. The engine ships **hundreds of unit tests** that run in **seconds, fully offline**, because every LLM call goes through an `LlmProvider` seam that tests satisfy with `MockLlmClient`: ```rust use smooth_operator_core::llm_provider::{LlmProvider, MockLlmClient}; @@ -272,7 +313,7 @@ flowchart TD J["LLM-as-judge evals — multi-turn quality, 0–5"] E["Live E2E — real gateway + WS, streamed answer"] C["Conformance — SQLite + Postgres stores, testcontainers"] - U["337 unit tests — MockLlmClient, offline, fast"] + U["hundreds of unit tests — MockLlmClient, offline, fast"] J --> E --> C --> U @@ -282,7 +323,7 @@ flowchart TD class U teal ``` -- **Unit (408):** the bulk. Loop control, tool dispatch, workflow edges, compaction, cost enforcement, HITL gating, checkpoint round-trips — all against `MockLlmClient`. +- **Unit (the bulk):** loop control, tool dispatch, workflow edges, compaction, cost enforcement, permission-gate + deny-policy verdicts, HITL gating, checkpoint round-trips — all against `MockLlmClient`. - **Conformance:** the `sqlite` and `postgres` checkpoint stores run the same suite against real engines (testcontainers), so "resume" means the same thing everywhere. - **Live E2E:** the smooth-operator service + [chat-widget](https://github.com/SmooAI/chat-widget) drive a real streamed, knowledge-grounded answer through a live gateway. - **LLM-as-judge:** multi-turn conversation quality is scored 0–5 by a judge model. This caught a real multi-turn context defect: a regression scored **1/5**, the fix landed, and it went back to **5/5** — a class of bug no assertion-based test would have flagged. @@ -309,17 +350,17 @@ cargo clippy --all-targets -- -D warnings ## Repository layout -This is a multi-language SmooAI package. The Rust crate is the reference; other languages mirror its surface. For install commands and a hello-agent example in every language, see [**docs/Polyglot-Engines.md**](./docs/Polyglot-Engines.md). +This is a multi-language SmooAI package. The Rust crate is the reference; the other four are **native ports at parity** — the same engine, idiomatic in each language, held to a shared eval suite. Each ships to its language's registry with its own README landing page. For install commands and a hello-agent example in every language, see [**docs/Polyglot-Engines.md**](./docs/Polyglot-Engines.md). -| Directory | Language | Status | -| --- | --- | --- | -| [`rust/`](./rust) | Rust (reference) | Active — crate `smooai-smooth-operator-core` (lib `smooth_operator_core`) | -| [`typescript/`](./typescript) | TypeScript | Planned | -| [`go/`](./go) | Go | Active — module `github.com/SmooAI/smooth-operator-core/go` | -| [`dotnet/`](./dotnet) | C# / .NET | Planned (first-class target) | -| [`python/`](./python) | Python | Planned | +| Language | Directory | Package | Registry | +| --- | --- | --- | --- | +| Rust (reference) | [`rust/`](./rust/smooth-operator-core) | `smooai-smooth-operator-core` (lib `smooth_operator_core`) | [crates.io](https://crates.io/crates/smooai-smooth-operator-core) | +| TypeScript | [`typescript/`](./typescript/core) | `@smooai/smooth-operator-core` | [npm](https://www.npmjs.com/package/@smooai/smooth-operator-core) | +| Python | [`python/`](./python/core) | `smooai-smooth-operator-core` | [PyPI](https://pypi.org/project/smooai-smooth-operator-core/) | +| Go | [`go/`](./go/core) | `github.com/SmooAI/smooth-operator-core/go/core` | [pkg.go.dev](https://pkg.go.dev/github.com/SmooAI/smooth-operator-core/go/core) | +| C# / .NET | [`dotnet/`](./dotnet/core) | `SmooAI.SmoothOperator.Core` | [nuget.org](https://www.nuget.org/packages/SmooAI.SmoothOperator.Core) | -Bindings follow a **protocol-first** strategy (a stable wire spec each language implements natively), with in-process FFI (napi-rs, PyO3/uniffi) layered on where embedding the engine pays off. +The ports follow a **protocol-first** strategy: a stable wire spec each language implements natively, so the loop, tool system, permission gate, checkpointing, and cost accounting behave the same everywhere. --- diff --git a/dotnet/core/README.md b/dotnet/core/README.md index c53758c..33e8a6f 100644 --- a/dotnet/core/README.md +++ b/dotnet/core/README.md @@ -15,11 +15,13 @@ --- -> The C#/.NET sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core). Agents, tools, knowledge/RAG, memory, checkpointing, human-in-the-loop, cost budgets, and workflows — as one embeddable NuGet package. It's the engine, not a notebook demo. +> ### The agent brain you can point at production — right in your .NET process. +> +> Most agent frameworks hand the model a pile of tools and hope. This one gives you the loop **and the brakes**: draw hard lines the model can never cross, then let it run. -`SmooAI.SmoothOperator.Core` is the **native C# implementation** of the Smoo AI agent engine — the in-process observe→think→act loop that powers [**lom.smoo.ai**](https://lom.smoo.ai). It's a sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) and one of the [polyglot set](https://github.com/SmooAI/smooth-operator-core/blob/main/docs/Polyglot-Engines.md) (Rust, TypeScript, Python, Go, C#/.NET) whose behavior is held at parity by a shared eval suite. Its API follows Microsoft.Extensions.AI naming. +`SmooAI.SmoothOperator.Core` is the agent engine itself, in-process — an observe→think→act loop over any `IChatClient`, with typed tools (authored from ordinary C# methods via `AIFunctionFactory`), streaming, checkpointing, cost budgets, and a permission gate you control. Its API follows Microsoft.Extensions.AI naming, so it drops into an existing .NET AI stack. Not a client to a remote server: the agent *is* your process. -It's a library, not a client to a remote server: it *is* the agent, running in your .NET process. Every surface is covered by **fast, offline tests** built on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. +It's the native C# port of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) — one of five siblings (Rust, TypeScript, Python, Go, C#/.NET) that share one wire spec and one eval suite. **The same agent brain, the same guarantees, wherever your stack already lives.** Every surface is covered by fast, offline tests on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. ## Install @@ -31,17 +33,30 @@ dotnet add package SmooAI.SmoothOperator.Core A complete agent — no credentials needed — using the deterministic mock provider the engine's own tests run on: +A complete agent with one tool — the mock is scripted to call the tool, then answer. Author tools from ordinary C# methods with `AIFunctionFactory.Create`: + ```csharp +using Microsoft.Extensions.AI; using SmooAI.SmoothOperator.Core; -var provider = new MockLlmProvider().PushText("the answer is 42"); -var agent = new SmoothAgent(provider, new AgentOptions { Instructions = "You are a helpful assistant" }); +var getWeather = AIFunctionFactory.Create( + (string city) => $"Weather in {city}: 72F, sunny", + "get_weather", + "Get the current weather for a city"); + +var provider = new MockLlmProvider() + .PushToolCall("call_1", "get_weather", new Dictionary { ["city"] = "Tokyo" }) + .PushText("It's 72F and sunny in Tokyo."); + +var options = new AgentOptions { Instructions = "You are a helpful assistant" }; +options.Tools.Add(getWeather); +var agent = new SmoothAgent(provider, options); -var response = await agent.RunAsync("what is the answer?"); +var response = await agent.RunAsync("what's the weather in Tokyo?"); Console.WriteLine(response.Text); ``` -`new SmoothAgent(chatClient, options)` takes an `IChatClient` (the `MockLlmProvider` implements it — swap in any OpenAI-compatible client) and an `AgentOptions`. `await agent.RunAsync(...)` returns an `AgentRunResponse`; `response.Text` is the final assistant message. +`new SmoothAgent(chatClient, options)` takes an `IChatClient` (the `MockLlmProvider` implements it — swap in any OpenAI-compatible client) and an `AgentOptions`; tools are `AITool`s added to `options.Tools`. `await agent.RunAsync(...)` returns an `AgentRunResponse`; `response.Text` is the final assistant message. ## Features @@ -57,6 +72,7 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Rerank** — rerank retrieved hits before injection (lexical reranker built in). - **Sub-agents / delegation** — spawn child agents for sub-tasks. - **Cast + clearance** — roles with per-role tool-access policy. +- **Permissions + deny-policy** — a tool-call gate (`AutoMode`: ask / accept-edits / deny-unmatched / bypass) with hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains), a persisted allow-list, and a consumer `DenyPolicy` — declarative TOML rules plus semantic predicates for what strings can't express. - **Human-in-the-loop gate** — require approval before designated tool calls run. - **Conversation thread** — carry a conversation across multiple `RunAsync` calls. - **`LlmProvider` seam + `MockLlmProvider`** — inject any OpenAI-compatible client; the record/replay mock drives the offline tests. @@ -66,6 +82,39 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Retry / backoff** — retry transient model-call failures with exponential backoff. - **Streaming** — stream incremental text, tool calls, and tool results as the turn runs. +## Permissions & deny-policy — lines the agent can't cross + +This is what makes an agent safe to point at real infrastructure: **you** decide what it can never do, and no prompt or model mistake talks it out of that. Every tool call passes through a gate. `AutoMode` sets the posture — read-only calls **allow**, mutating calls **ask**, dangerous calls **deny** — and hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains) fire in every mode, `Bypass` included. Attach a `DenyPolicy` on top: declarative TOML rules for the lines you can name, semantic predicates for the ones you can't. A match is a hard deny no stored grant and no mode can waive. + +```csharp +using Microsoft.Extensions.AI; +using SmooAI.SmoothOperator.Core; + +// Declarative rules (TOML): never the prod AWS profile, never a prod host. +var policy = DenyPolicy.FromToml(@" + schema_version = 1 + [bash] + deny_patterns = [""aws * --profile prod""] + [network] + deny_hosts = [""*.prod.internal""] +").WithPredicate(new DenyDbWriter()); + +var options = new AgentOptions { Instructions = "You are a careful assistant" } + .WithPermissionMode(AutoMode.Ask) // read allow · mutate ask · dangerous deny + .WithDenyPolicy(policy); +options.Tools.Add(getWeather); +var agent = new SmoothAgent(provider, options); + +// A predicate for what strings can't express — return a DenyReason to deny, null to allow. +sealed class DenyDbWriter : IDenyPredicate +{ + public DenyReason? Evaluate(FunctionCallContent call) => + call.Name == "db_query" && call.Arguments?.Values.Any(v => $"{v}".Contains("writer")) == true + ? new DenyReason("DB writer endpoint is off-limits — reads go to the replica") + : null; +} +``` + ## Streaming `RunStreamingAsync` is the streaming variant of `RunAsync`: it yields incremental updates — text deltas as the model produces them, each tool call before dispatch, each tool result after it finishes, and a terminal update carrying the same response `RunAsync` would have returned. diff --git a/go/core/README.md b/go/core/README.md index fce9be9..71731c6 100644 --- a/go/core/README.md +++ b/go/core/README.md @@ -15,11 +15,13 @@ --- -> The Go sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core). Agents, tools, knowledge/RAG, memory, checkpointing, human-in-the-loop, cost budgets, and workflows — as one embeddable package. It's the engine, not a notebook demo. +> ### The agent brain you can point at production — right in your Go process. +> +> Most agent frameworks hand the model a pile of tools and hope. This one gives you the loop **and the brakes**: draw hard lines the model can never cross, then let it run. -`github.com/SmooAI/smooth-operator-core/go/core` is the **native Go implementation** of the Smoo AI agent engine — the in-process observe→think→act loop that powers [**lom.smoo.ai**](https://lom.smoo.ai). It's a sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) and one of the [polyglot set](https://github.com/SmooAI/smooth-operator-core/blob/main/docs/Polyglot-Engines.md) (Rust, TypeScript, Python, Go, C#/.NET) whose behavior is held at parity by a shared eval suite. +`github.com/SmooAI/smooth-operator-core/go/core` is the agent engine itself, in-process — an observe→think→act loop over any OpenAI-compatible client, with typed tools, streaming, checkpointing, cost budgets, and a permission gate you control. Not a client to a remote server: the agent *is* your process. -It's a library, not a client to a remote server: it *is* the agent, running in your Go process. Every surface is covered by **fast, offline tests** built on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. +It's the native Go port of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) — one of five siblings (Rust, TypeScript, Python, Go, C#/.NET) that share one wire spec and one eval suite. **The same agent brain, the same guarantees, wherever your stack already lives.** Every surface is covered by fast, offline tests on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. ## Install @@ -33,6 +35,8 @@ The engine is the `core` package; idiomatic alias `core`. A complete agent — no credentials needed — using the deterministic mock provider the engine's own tests run on: +A complete agent with one tool — the mock is scripted to call the tool, then answer: + ```go package main @@ -44,10 +48,29 @@ import ( ) func main() { - provider := core.NewMockLlmProvider().PushText("the answer is 42") - agent := core.NewSmoothAgent(provider, core.AgentOptions{Instructions: "You are a helpful assistant"}) + weather := core.FuncTool{ + ToolName: "get_weather", + Desc: "Get the current weather for a city", + Params: map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []string{"city"}, + }, + Fn: func(ctx context.Context, args map[string]any) (string, error) { + return fmt.Sprintf("Weather in %v: 72F, sunny", args["city"]), nil + }, + } + + provider := core.NewMockLlmProvider(). + PushToolCall("call_1", "get_weather", `{"city":"Tokyo"}`). + PushText("It's 72F and sunny in Tokyo.") + + agent := core.NewSmoothAgent(provider, core.AgentOptions{ + Instructions: "You are a helpful assistant", + Tools: []core.Tool{weather}, + }) - res, err := agent.Run(context.Background(), "what is the answer?", nil) + res, err := agent.Run(context.Background(), "what's the weather in Tokyo?", nil) if err != nil { panic(err) } @@ -55,7 +78,7 @@ func main() { } ``` -`NewSmoothAgent(client, options)` takes a `ChatClient` (the `MockLlmProvider` implements it — swap in any OpenAI-compatible client) and an `AgentOptions` struct. `Run(ctx, message, history)` — pass `nil` history for a fresh turn — returns `(AgentRunResponse, error)`; `res.Text` is the final answer. +`NewSmoothAgent(client, options)` takes a `ChatClient` (the `MockLlmProvider` implements it — swap in any OpenAI-compatible client) and an `AgentOptions` struct. `FuncTool` wraps a function as a `Tool`. `Run(ctx, message, history)` — pass `nil` history for a fresh turn — returns `(AgentRunResponse, error)`; `res.Text` is the final answer. ## Features @@ -71,6 +94,7 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Rerank** — rerank retrieved hits before injection (lexical reranker built in). - **Sub-agents / delegation** — spawn child agents for sub-tasks. - **Cast + clearance** — roles with per-role tool-access policy. +- **Permissions + deny-policy** — a tool-call gate (`AutoMode`: ask / accept-edits / deny-unmatched / bypass) with hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains), a persisted allow-list, and a consumer `DenyPolicy` — declarative TOML rules plus semantic predicates for what strings can't express. - **Human-in-the-loop gate** — require approval before designated tool calls run. - **Conversation thread** — carry a conversation across multiple `Run` calls. - **`LlmProvider` seam + `MockLlmProvider`** — inject any OpenAI-compatible client; the record/replay mock drives the offline tests. @@ -80,6 +104,43 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Retry / backoff** — retry transient model-call failures with exponential backoff. - **Streaming** — stream incremental text, tool calls, and tool results as the turn runs. +## Permissions & deny-policy — lines the agent can't cross + +This is what makes an agent safe to point at real infrastructure: **you** decide what it can never do, and no prompt or model mistake talks it out of that. Every tool call passes through a gate. `AutoMode` sets the posture — read-only calls **allow**, mutating calls **ask**, dangerous calls **deny** — and hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains) fire in every mode, `AutoModeBypass` included. Attach a `DenyPolicy` on top: declarative TOML rules for the lines you can name, semantic predicates for the ones you can't. A match is a hard deny no stored grant and no mode can waive. + +```go +// A DenyPredicate for what strings can't express — return (reason, true) to deny. +type denyDbWriter struct{} + +func (denyDbWriter) Evaluate(name string, args map[string]any) (core.DenyReason, bool) { + if name == "db_query" && strings.Contains(fmt.Sprint(args), "writer") { + return core.NewDenyReason("DB writer endpoint is off-limits — reads go to the replica"), true + } + return core.DenyReason{}, false +} + +// Declarative rules (TOML): never the prod AWS profile, never a prod host. +policy, err := core.DenyPolicyFromTOML(` + schema_version = 1 + [bash] + deny_patterns = ["aws * --profile prod"] + [network] + deny_hosts = ["*.prod.internal"] +`) +if err != nil { + panic(err) +} +policy = policy.WithPredicate(denyDbWriter{}) + +mode := core.AutoModeAsk +agent := core.NewSmoothAgent(provider, core.AgentOptions{ + Instructions: "You are a careful assistant", + Tools: []core.Tool{weather}, + PermissionMode: &mode, // read allow · mutate ask · dangerous deny + DenyPolicy: policy, +}) +``` + ## Streaming `RunStream` is the streaming variant of `Run`: it yields incremental events — `text` deltas as the model produces them, each tool call before dispatch, each tool result after it finishes, and a terminal `done` event carrying the same response `Run` would have returned. diff --git a/python/core/README.md b/python/core/README.md index d27a72f..0fbaf7a 100644 --- a/python/core/README.md +++ b/python/core/README.md @@ -15,11 +15,13 @@ --- -> The Python sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core). Agents, tools, knowledge/RAG, memory, checkpointing, human-in-the-loop, cost budgets, and workflows — as one embeddable package. It's the engine, not a notebook demo. +> ### The agent brain you can point at production — right in your Python process. +> +> Most agent frameworks hand the model a pile of tools and hope. This one gives you the loop **and the brakes**: draw hard lines the model can never cross, then let it run. -`smooai-smooth-operator-core` is the **native Python implementation** of the Smoo AI agent engine — the in-process observe→think→act loop that powers [**lom.smoo.ai**](https://lom.smoo.ai). It's a sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) and one of the [polyglot set](https://github.com/SmooAI/smooth-operator-core/blob/main/docs/Polyglot-Engines.md) (Rust, TypeScript, Python, Go, C#/.NET) whose behavior is held at parity by a shared eval suite. +`smooai-smooth-operator-core` is the agent engine itself, in-process — an observe→think→act loop over any OpenAI-compatible client, with typed tools, streaming, checkpointing, cost budgets, and a permission gate you control. Not a client to a remote server: the agent *is* your process. -It's a library, not a client to a remote server: it *is* the agent, running in your Python process. Every surface is covered by **fast, offline tests** built on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. +It's the native Python port of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) — one of five siblings (Rust, TypeScript, Python, Go, C#/.NET) that share one wire spec and one eval suite. **The same agent brain, the same guarantees, wherever your stack already lives.** Every surface is covered by fast, offline tests on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. ## Install @@ -33,22 +35,36 @@ Import as `smooth_operator_core`. A complete agent — no credentials needed — using the deterministic mock provider the engine's own tests run on: +A complete agent with one tool — the mock is scripted to call the tool, then answer: + ```python import asyncio -from smooth_operator_core import SmoothAgent, AgentOptions, MockLlmProvider +import json +from smooth_operator_core import SmoothAgent, AgentOptions, FunctionTool, MockLlmProvider + +async def get_weather(args): + return f"Weather in {args['city']}: 72F, sunny" async def main(): + weather = FunctionTool( + name="get_weather", + description="Get the current weather for a city", + parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + func=get_weather, + ) + provider = MockLlmProvider() - provider.push_text("the answer is 42") + provider.push_tool_call("call_1", "get_weather", json.dumps({"city": "Tokyo"})) + provider.push_text("It's 72F and sunny in Tokyo.") - agent = SmoothAgent(provider, AgentOptions(instructions="You are a helpful assistant")) - result = await agent.run("what is the answer?") + agent = SmoothAgent(provider, AgentOptions(instructions="You are a helpful assistant", tools=[weather])) + result = await agent.run("what's the weather in Tokyo?") print(result.text) asyncio.run(main()) ``` -`SmoothAgent(chat_client, options)` takes the provider (the `MockLlmProvider` — swap in any OpenAI-compatible client) and an `AgentOptions` dataclass (all fields default, so `AgentOptions()` is valid). `await agent.run(...)` returns an `AgentRunResponse`; `result.text` is the final answer. +`SmoothAgent(chat_client, options)` takes the provider (the `MockLlmProvider` — swap in any OpenAI-compatible client) and an `AgentOptions` dataclass (all fields default, so `AgentOptions()` is valid). `FunctionTool` wraps an async function as a tool. `await agent.run(...)` returns an `AgentRunResponse`; `result.text` is the final answer. ## Features @@ -64,6 +80,7 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Rerank** — rerank retrieved hits before injection (lexical reranker built in). - **Sub-agents / delegation** — spawn child agents for sub-tasks. - **Cast + clearance** — roles with per-role tool-access policy. +- **Permissions + deny-policy** — a tool-call gate (`AutoMode`: ask / accept-edits / deny-unmatched / bypass) with hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains), a persisted allow-list, and a consumer `DenyPolicy` — declarative TOML rules plus semantic predicates for what strings can't express. - **Human-in-the-loop gate** — require approval before designated tool calls run. - **Conversation thread** — `SmoothAgentThread` carries a conversation across multiple `run` calls. - **`LlmProvider` seam + `MockLlmProvider`** — inject any OpenAI-compatible client; the record/replay mock drives the offline tests. @@ -73,6 +90,44 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Retry / backoff** — retry transient model-call failures with exponential backoff. - **Streaming** — stream incremental text, tool calls, and tool results as the turn runs. +## Permissions & deny-policy — lines the agent can't cross + +This is what makes an agent safe to point at real infrastructure: **you** decide what it can never do, and no prompt or model mistake talks it out of that. Every tool call passes through a gate. `AutoMode` sets the posture — read-only calls **allow**, mutating calls **ask**, dangerous calls **deny** — and hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains) fire in every mode, `BYPASS` included. Attach a `DenyPolicy` on top: declarative TOML rules for the lines you can name, semantic predicates for the ones you can't. A match is a hard deny no stored grant and no mode can waive. + +```python +from smooth_operator_core import ( + SmoothAgent, AgentOptions, AutoMode, DenyPolicy, DenyPredicate, DenyReason, +) + +# Declarative rules (TOML): never the prod AWS profile, never a prod host. +policy = DenyPolicy.from_toml( + """ + schema_version = 1 + [bash] + deny_patterns = ["aws * --profile prod"] + [network] + deny_hosts = ["*.prod.internal"] + """ +) + +# Predicate for what strings can't express — return a DenyReason to deny, None to allow. +class DenyDbWriter(DenyPredicate): + def evaluate(self, call): + if call.name == "db_query" and "writer" in str(call.arguments): + return DenyReason.new("DB writer endpoint is off-limits — reads go to the replica") + return None + +agent = SmoothAgent( + provider, + AgentOptions( + instructions="You are a careful assistant", + tools=[weather], + permission_mode=AutoMode.ASK, # read allow · mutate ask · dangerous deny + deny_policy=policy.with_predicate(DenyDbWriter()), + ), +) +``` + ## Streaming `run_stream` is the async streaming variant of `run`: it yields incremental events — `text` deltas as the model produces them, each tool call before dispatch, each tool result after it finishes, and a terminal `done` event carrying the same response `run` would have returned. diff --git a/rust/smooth-operator-core/README.md b/rust/smooth-operator-core/README.md index 29ff1cf..3f2f3fb 100644 --- a/rust/smooth-operator-core/README.md +++ b/rust/smooth-operator-core/README.md @@ -15,11 +15,13 @@ --- -> The agent runtime behind the [smooth-operator](https://github.com/SmooAI/smooth-operator) service and [lom.smoo.ai](https://lom.smoo.ai). Agents, workflows, tools, checkpointing, memory, human-in-the-loop, and per-model cost budgets — as a single embeddable Rust crate. It's the engine, not a notebook demo. +> ### The agent brain you can point at production — a single embeddable Rust crate. +> +> Most agent frameworks hand the model a pile of tools and hope. This one gives you the loop **and the brakes**: draw hard lines the model can never cross, then let it run. -`smooai-smooth-operator-core` is the **reference implementation** of the Smoo AI agent engine — the observe→think→act loop that powers the [**smooth-operator**](https://github.com/SmooAI/smooth-operator) service and [**lom.smoo.ai**](https://lom.smoo.ai). It gives you the moving parts of a serious agent framework — a typed tool system, a graph workflow engine, pluggable checkpoint stores, memory, RAG, human-in-the-loop gates, and per-model cost budgets — as one embeddable crate. +`smooai-smooth-operator-core` is the agent engine itself — an observe→think→act loop over any OpenAI-compatible client, with a typed tool system, pre/post hooks, pluggable checkpoint stores, memory, RAG, human-in-the-loop gates, per-model cost budgets, and a permission gate you control. One crate; runs in a Lambda, a container, any host process. It's the runtime the [**smooth-operator**](https://github.com/SmooAI/smooth-operator) service actually ships on. -This Rust crate is the source of truth: the [TypeScript, Python, Go, and C#/.NET engines](https://github.com/SmooAI/smooth-operator-core/blob/main/docs/Polyglot-Engines.md) mirror its behavior. Every surface is covered by **fast, offline unit tests** built on a deterministic `MockLlmClient`, so the loop is verified — not vibe-coded. +This crate is the **reference implementation** — the source of truth the [TypeScript, Python, Go, and C#/.NET ports](https://github.com/SmooAI/smooth-operator-core/blob/main/docs/Polyglot-Engines.md) mirror at parity. **The same agent brain, the same guarantees, wherever your stack already lives.** Every surface is covered by fast, offline unit tests on a deterministic `MockLlmClient`, so the loop is verified — not vibe-coded. ## Install @@ -33,23 +35,48 @@ The crate is `smooai-smooth-operator-core` (library `smooth_operator_core`), v0. ## Quickstart -A complete agent — no credentials needed — using the deterministic mock provider the engine's own tests run on: +A complete agent with one tool — no credentials needed — using the deterministic mock provider the engine's own tests run on. The mock is scripted to call the tool, then answer: ```rust use std::sync::Arc; -use smooth_operator_core::{Agent, AgentConfig, LlmConfig, ToolRegistry}; +use async_trait::async_trait; +use smooth_operator_core::{Agent, AgentConfig, LlmConfig, Tool, ToolRegistry, ToolSchema}; use smooth_operator_core::llm_provider::MockLlmClient; +struct GetWeather; + +#[async_trait] +impl Tool for GetWeather { + fn schema(&self) -> ToolSchema { + ToolSchema { + name: "get_weather".into(), + description: "Get the current weather for a city".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"] + }), + } + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + Ok(format!("Weather in {}: 72F, sunny", args["city"].as_str().unwrap_or("?"))) + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let mock = MockLlmClient::new(); - mock.push_text("the answer is 42"); + mock.push_tool_call("call_1", "get_weather", serde_json::json!({ "city": "Tokyo" })); + mock.push_text("It's 72F and sunny in Tokyo."); + + let mut registry = ToolRegistry::new(); + registry.register(GetWeather); let config = AgentConfig::new("agent", "You are a helpful assistant", LlmConfig::openrouter("fake-key")); - let agent = Agent::new(config, ToolRegistry::new()) - .with_llm_provider(Arc::new(mock.clone())); + let agent = Agent::new(config, registry).with_llm_provider(Arc::new(mock.clone())); - let conversation = agent.run("what is the answer?").await?; + let conversation = agent.run("what's the weather in Tokyo?").await?; println!("{}", conversation.last_assistant_content().unwrap_or("")); Ok(()) } @@ -71,6 +98,7 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Rerank** — rerank retrieved hits before injection (lexical reranker built in). - **Sub-agents / delegation** — spawn child agents for sub-tasks. - **Cast + clearance** — roles with per-role tool-access policy. +- **Permissions + deny-policy** — a `PermissionHook` tool-call gate (`AutoMode`: ask / accept-edits / deny-unmatched / bypass) with hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains), a persisted allow-list, and a consumer `DenyPolicy` — declarative TOML rules plus semantic predicates for what strings can't express. - **Human-in-the-loop gate** — `ConfirmationHook` requires approval before designated tool calls run. - **Conversation thread** — carry a conversation across multiple `run` calls. - **`LlmProvider` seam + `MockLlmClient`** — inject any OpenAI-compatible client; the record/replay mock drives the offline tests. @@ -80,6 +108,42 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Retry / backoff** — retry transient model-call failures with exponential backoff. - **Streaming** — incremental text, tool calls, and tool results as the turn runs. +## Permissions & deny-policy — lines the agent can't cross + +This is what makes an agent safe to point at real infrastructure: **you** decide what it can never do, and no prompt or model mistake talks it out of that. Every tool call passes through a gate. `AutoMode` sets the posture — read-only calls **allow**, mutating calls **ask**, dangerous calls **deny** — and hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains) fire in every mode, `Bypass` included. Attach a `DenyPolicy` on top: declarative TOML rules for the lines you can name, semantic predicates for the ones you can't. A match is a hard deny no stored grant and no mode can waive. + +```rust +use std::sync::Arc; +use smooth_operator_core::{Agent, AutoMode, DenyPolicy, DenyPredicate, DenyReason, ToolCall}; + +// A predicate for what strings can't express — return Some(reason) to deny. +struct DenyDbWriter; +impl DenyPredicate for DenyDbWriter { + fn evaluate(&self, call: &ToolCall) -> Option { + if call.name == "db_query" && call.arguments.to_string().contains("writer") { + return Some(DenyReason::new("DB writer endpoint is off-limits — reads go to the replica")); + } + None + } +} + +// Declarative rules (TOML): never the prod AWS profile, never a prod host. +let policy = DenyPolicy::from_toml(r#" + schema_version = 1 + [bash] + deny_patterns = ["aws * --profile prod"] + [network] + deny_hosts = ["*.prod.internal"] +"#)?.with_predicate(Arc::new(DenyDbWriter)); + +let agent = Agent::new(config, registry) + .with_permission_mode(AutoMode::Ask) // read allow · mutate ask · dangerous deny + .with_deny_policy(Arc::new(policy)) + .with_extension_host(host); // the gate is installed here; set mode/policy first +``` + +The gate is installed by `with_extension_host` (the SEP extension host), so call `with_permission_mode` / `with_deny_policy` **before** it. + ## Streaming For live token deltas, tool-call, and tool-result events, drive the agent with `run_with_channel(msg, tx)` and consume the `AgentEvent` stream off the receiver — instead of `run`, which returns a single completed `Conversation`. diff --git a/typescript/core/README.md b/typescript/core/README.md index b896e4a..21c8900 100644 --- a/typescript/core/README.md +++ b/typescript/core/README.md @@ -15,11 +15,13 @@ --- -> The TypeScript sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core). Agents, tools, knowledge/RAG, memory, checkpointing, human-in-the-loop, cost budgets, and workflows — as one embeddable npm package. It's the engine, not a notebook demo. +> ### The agent brain you can point at production — right in your Node process. +> +> Most agent frameworks hand the model a pile of tools and hope. This one gives you the loop **and the brakes**: draw hard lines the model can never cross, then let it run. -`@smooai/smooth-operator-core` is the **native TypeScript implementation** of the Smoo AI agent engine — the in-process observe→think→act loop that powers [**lom.smoo.ai**](https://lom.smoo.ai). It's a sibling of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) and one of the [polyglot set](https://github.com/SmooAI/smooth-operator-core/blob/main/docs/Polyglot-Engines.md) (Rust, TypeScript, Python, Go, C#/.NET) whose behavior is held at parity by a shared eval suite. +`@smooai/smooth-operator-core` is the agent engine itself, in-process — an observe→think→act loop over any OpenAI-compatible client, with typed tools, streaming, checkpointing, cost budgets, and a permission gate you control. Not a client to a remote server: the agent *is* your process. -It's a library, not a client to a remote server: it *is* the agent, running in your Node process. Every surface is covered by **fast, offline tests** built on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. +It's the native TypeScript port of the [Rust reference engine](https://github.com/SmooAI/smooth-operator-core) — one of five siblings (Rust, TypeScript, Python, Go, C#/.NET) that share one wire spec and one eval suite. **The same agent brain, the same guarantees, wherever your stack already lives.** Every surface is covered by fast, offline tests on a deterministic `MockLlmProvider`, so the loop is verified — not vibe-coded. ## Install @@ -29,19 +31,34 @@ npm install @smooai/smooth-operator-core ## Quickstart -A complete agent — no credentials needed — using the deterministic mock provider the engine's own tests run on: +A complete agent with one tool — no credentials needed — using the deterministic mock provider the engine's own tests run on. The mock is scripted to call the tool, then answer: ```ts -import { SmoothAgent, MockLlmProvider } from '@smooai/smooth-operator-core'; - -const provider = new MockLlmProvider().pushText('the answer is 42'); -const agent = new SmoothAgent(provider, { instructions: 'You are a helpful assistant' }); - -const response = await agent.run('what is the answer?'); +import { SmoothAgent, MockLlmProvider, type Tool } from '@smooai/smooth-operator-core'; + +const getWeather: Tool = { + name: 'get_weather', + description: 'Get the current weather for a city', + parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, + async execute(args) { + return `Weather in ${args.city}: 72F, sunny`; + }, +}; + +const provider = new MockLlmProvider() + .pushToolCall('call_1', 'get_weather', JSON.stringify({ city: 'Tokyo' })) + .pushText("It's 72F and sunny in Tokyo."); + +const agent = new SmoothAgent(provider, { + instructions: 'You are a helpful assistant', + tools: [getWeather], +}); + +const response = await agent.run("what's the weather in Tokyo?"); console.log(response.text); ``` -`SmoothAgent`'s constructor takes a `ChatClientLike` (the `MockLlmProvider` implements it — swap in any OpenAI-compatible client) and an `AgentOptions` object. `run` returns an `AgentRunResponse` whose `text` is the final answer. +`SmoothAgent`'s constructor takes a `ChatClientLike` (the `MockLlmProvider` implements it — swap in any OpenAI-compatible client) and an `AgentOptions` object. A `Tool` is a `{ name, description, parameters, execute }` object. `run` returns an `AgentRunResponse` whose `text` is the final answer. ## Features @@ -57,6 +74,7 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Rerank** — `LexicalReranker` reranks retrieved hits before injection. - **Sub-agents / delegation** — `delegateTool` spawns child agents for sub-tasks. - **Cast + clearance** — `Cast`, `Clearance`, `makeRole` for per-role tool-access policy. +- **Permissions + deny-policy** — a tool-call gate (`AutoMode`: ask / accept-edits / deny-unmatched / bypass) with hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains), a persisted allow-list, and a consumer `DenyPolicy` — declarative TOML rules plus semantic predicates for what strings can't express. - **Human-in-the-loop gate** — `HumanGate` requires approval before designated tool calls run. - **Conversation thread** — `SmoothAgentThread` carries a conversation across multiple `run` calls. - **`LlmProvider` seam + `MockLlmProvider`** — inject any OpenAI-compatible client; the record/replay mock drives the offline tests. @@ -66,6 +84,36 @@ The full parity surface — every engine in the [polyglot set](https://github.co - **Retry / backoff** — retry transient model-call failures with exponential backoff. - **Streaming** — stream incremental text, tool calls, and tool results as the turn runs. +## Permissions & deny-policy — lines the agent can't cross + +This is what makes an agent safe to point at real infrastructure: **you** decide what it can never do, and no prompt or model mistake talks it out of that. Every tool call passes through a gate. `AutoMode` sets the posture — read-only calls **allow**, mutating calls **ask**, dangerous calls **deny** — and hard circuit-breakers (`rm -rf /`, credential paths, pipe-to-shell, dangerous domains) fire in every mode, `Bypass` included. Attach a `DenyPolicy` on top: declarative TOML rules for the lines you can name, semantic predicates for the ones you can't. A match is a hard deny no stored grant and no mode can waive. + +```ts +import { SmoothAgent, AutoMode, DenyPolicy, type DenyPredicate } from '@smooai/smooth-operator-core'; + +// Declarative rules (TOML): never the prod AWS profile, never a prod host. +const policy = DenyPolicy.fromToml(` + schema_version = 1 + [bash] + deny_patterns = ["aws * --profile prod"] + [network] + deny_hosts = ["*.prod.internal"] +`); + +// Predicate for what strings can't express — return a reason to deny, or undefined to allow. +const denyDbWriter: DenyPredicate = (call) => + call.name === 'db_query' && /writer/.test(JSON.stringify(call.arguments)) + ? 'DB writer endpoint is off-limits — reads go to the replica' + : undefined; + +const agent = new SmoothAgent(provider, { + instructions: 'You are a careful assistant', + tools: [getWeather], + permissionMode: AutoMode.Ask, // read allow · mutate ask · dangerous deny + denyPolicy: policy.withPredicate(denyDbWriter), +}); +``` + ## Streaming `runStream` is an async generator over a `StreamEvent` tagged union (discriminated on `type`): `text` deltas as the model produces them, each `tool_call` before dispatch, each `tool_result` after it finishes, and a terminal `done` event carrying the same response `run` would have returned.