From b1b90c3ab97c697788acf3893ac7918d485c89c7 Mon Sep 17 00:00:00 2001 From: LeonHibnik Date: Mon, 12 Oct 2026 13:45:48 +0300 Subject: [PATCH 1/4] feat: add Asimi support as a new harness --- CHANGELOG.md | 7 ++++ README.md | 1 + scripts/ci/check-clients.mjs | 5 ++- scripts/ci/check-live.mjs | 4 +- src/catalog.ts | 3 +- src/engine/constants.ts | 2 +- src/engine/tools/asimi.ts | 59 +++++++++++++++++++++++++++ src/run.ts | 4 +- tests/adapters.test.ts | 3 +- tests/asimi.test.ts | 77 ++++++++++++++++++++++++++++++++++++ 10 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 src/engine/tools/asimi.ts create mode 100644 tests/asimi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d4646..3de344f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Asimi support.** [Asimi](https://asimi.dev/), the open-source vi-inspired coding agent, is now a + supported harness (`zro asimi`). Asimi is launched entirely through its provider env conventions + (`ZRO_API_KEY`/`ZRO_BASE_URL` with `ASIMI_PROVIDER=zro`), so no config files are written and the + API key never touches disk. Install with `zro install asimi`. + ## [0.3.0] - 2026-10-05 ### Highlights diff --git a/README.md b/README.md index 0ec66a3..bce511b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ zro logout # remove the stored login | [OpenClaw](https://github.com/openclaw/openclaw) | `zro install openclaw` | Personal AI assistant | | [Pi](https://github.com/earendil-works/pi) | `zro install pi` | Minimal coding agent | | [Prime Agent](https://primeintellect.ai) | `zro install prime` | Prime Intellect's self-improving RLM agent | +| [Asimi](https://asimi.dev/) | `zro install asimi` | Open-source vi-inspired coding agent | Each adapter launches the installed tool as a child process with isolated, Zro-owned configuration. Session files are temporary; Codex App uses a persistent Zro-owned home. diff --git a/scripts/ci/check-clients.mjs b/scripts/ci/check-clients.mjs index 43d4421..399f177 100644 --- a/scripts/ci/check-clients.mjs +++ b/scripts/ci/check-clients.mjs @@ -14,7 +14,7 @@ if (!apiKey || apiKey === "ci-fake-key") { } const zroBin = process.env.ZRO_BIN || "zro"; const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-models-report.json"); -const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime"]; +const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime", "asimi"]; const requestedHarness = process.argv[2] || "all"; if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) { throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`); @@ -71,7 +71,8 @@ try { pi: ["pi", "--version"], hermes: ["hermes", "--version"], openclaw: ["openclaw", "--version"], - prime: ["prime-agent", "--version"] + prime: ["prime-agent", "--version"], + asimi: ["asimi", "--version"] }; for (const name of harnesses) { const command = clientCommands[name]; diff --git a/scripts/ci/check-live.mjs b/scripts/ci/check-live.mjs index 5f88da8..ebacfbb 100644 --- a/scripts/ci/check-live.mjs +++ b/scripts/ci/check-live.mjs @@ -15,7 +15,7 @@ const REASONING_TIMEOUT_MS = 300_000; // discovered by the matrix (so version + compatibility checks run), but they // have no cache/reasoning probes. They are recorded as explicitly skipped so // the report never shows a green `ok: true` with an empty `checks` array. -const SKIPPED_LIVE_HARNESSES = new Set(["hermes", "openclaw"]); +const SKIPPED_LIVE_HARNESSES = new Set(["hermes", "openclaw", "asimi"]); if (!apiKey || apiKey === "ci-fake-key") { throw new Error("ZRO_API_KEY must contain a live API test key"); } @@ -23,7 +23,7 @@ if (!apiKey || apiKey === "ci-fake-key") { const zroBin = process.env.ZRO_BIN || "zro"; const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-live-report.json"); const runId = (process.env.ZRO_CI_RUN_ID || `${Date.now()}`).replace(/[^A-Za-z0-9_.-]/g, "-"); -const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime"]; +const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime", "asimi"]; const requestedHarness = process.argv[2] || "all"; if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) { throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`); diff --git a/src/catalog.ts b/src/catalog.ts index 7b3de64..2947d0c 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -25,7 +25,8 @@ export const TOOLS: readonly ToolDescriptor[] = [ { id: "hermes", name: "Hermes", hint: "Nous Research's agent", aliases: ["h"], executable: "hermes", installer: { url: "https://hermes-agent.nousresearch.com/install.sh", args: ["--skip-setup"], binDir: ".local/bin" } }, { id: "openclaw", name: "OpenClaw", hint: "Personal AI assistant", aliases: ["claw"], executable: "openclaw", package: "openclaw@latest" }, { id: "pi", name: "Pi", hint: "Minimal coding agent", aliases: [], executable: "pi", package: "@earendil-works/pi-coding-agent@latest" }, - { id: "prime", name: "Prime Agent", hint: "Prime Intellect's self-improving RLM agent", aliases: ["pa", "prime-agent"], executable: "prime-agent", installer: { url: "https://app.primeintellect.ai/prime-agent/install.sh", binDir: ".local/bin" } } + { id: "prime", name: "Prime Agent", hint: "Prime Intellect's self-improving RLM agent", aliases: ["pa", "prime-agent"], executable: "prime-agent", installer: { url: "https://app.primeintellect.ai/prime-agent/install.sh", binDir: ".local/bin" } }, + { id: "asimi", name: "Asimi", hint: "Open-source vi-inspired coding agent", aliases: [], executable: "asimi", installer: { url: "https://asimi.dev/installer", binDir: ".local/bin" } } ] as const; const TOOL_LOOKUP = new Map(); diff --git a/src/engine/constants.ts b/src/engine/constants.ts index f796706..5701ed2 100644 --- a/src/engine/constants.ts +++ b/src/engine/constants.ts @@ -39,4 +39,4 @@ export function supportsAttachments(model: Pick): boolea return model.modalities.input.some((modality) => modality !== "text"); } -export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "hermes", "openclaw", "pi", "prime"] as const; +export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "hermes", "openclaw", "pi", "prime", "asimi"] as const; diff --git a/src/engine/tools/asimi.ts b/src/engine/tools/asimi.ts new file mode 100644 index 0000000..641eae2 --- /dev/null +++ b/src/engine/tools/asimi.ts @@ -0,0 +1,59 @@ +import { BASE_URL, PROVIDER_ID, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import type { ToolModule } from "../types.js"; + +// Asimi resolves API keys and base URLs by convention from the provider id: +// _API_KEY and _BASE_URL. With ASIMI_PROVIDER=zro that is +// ZRO_API_KEY (the shared ZRO_ENV_KEY) and ZRO_BASE_URL. Asimi normalizes the +// base URL itself (strips the /v1 suffix before its OpenAI client re-appends it). +// ASIMI_MODEL/ASIMI_REASONING_EFFORT env overrides beat its config file; the +// --model flag beats both. +const ASIMI_PROVIDER_ENV_KEY = "ASIMI_PROVIDER"; +const ASIMI_MODEL_ENV_KEY = "ASIMI_MODEL"; +const ASIMI_REASONING_EFFORT_ENV_KEY = "ASIMI_REASONING_EFFORT"; +const ASIMI_BASE_URL_ENV_KEY = `${PROVIDER_ID.toUpperCase()}_BASE_URL`; + +// Asimi only accepts low|medium|high. Catalog levels outside that set (off, +// minimal, xhigh) map to the nearest accepted effort; this can differ from +// what the server sent, so the env var is omitted entirely when there is no +// sensible mapping rather than guessing. +const EFFORT_BY_PI_LEVEL: Partial> = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "high" +}; + +export const asimiTool: ToolModule = { + id: "asimi", + label: "Asimi", + async launch(ctx) { + const model = ctx.models.find((candidate) => candidate.id === ctx.model); + const env: Record = { + [ZRO_ENV_KEY]: ctx.apiKey, + [ASIMI_BASE_URL_ENV_KEY]: BASE_URL, + [ASIMI_PROVIDER_ENV_KEY]: PROVIDER_ID, + [ASIMI_MODEL_ENV_KEY]: ctx.model + }; + const effort = model ? reasoningEffort(model) : undefined; + if (effort) { + env[ASIMI_REASONING_EFFORT_ENV_KEY] = effort; + } + return { + tool: "asimi", + label: "Asimi", + model: ctx.model, + command: "asimi", + args: ["--model", ctx.model, "--provider", PROVIDER_ID, ...ctx.extraArgs], + env, + message: "Launch Asimi with Zro" + }; + } +}; + +export function reasoningEffort(model: ZroModel): string | undefined { + const level = model.reasoning.levels.find( + (candidate) => candidate.id === model.reasoning.defaultLevel + ); + return level ? EFFORT_BY_PI_LEVEL[level.piLevel] : undefined; +} diff --git a/src/run.ts b/src/run.ts index 42d9c5b..8bcbfbd 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,6 +24,7 @@ import { codexAppTool, codexTool, } from "./engine/tools/codex.js"; +import { asimiTool } from "./engine/tools/asimi.js"; import { grokTool } from "./engine/tools/grok.js"; import { hermesTool } from "./engine/tools/hermes.js"; import { kiloTool } from "./engine/tools/kilo.js"; @@ -59,7 +60,8 @@ const tools: Record = { openclaw: openClawTool, opencode: opencodeTool, pi: piTool, - prime: primeTool + prime: primeTool, + asimi: asimiTool }; const PACKAGE_VERSION = ( diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts index 457f5c9..21eeba2 100644 --- a/tests/adapters.test.ts +++ b/tests/adapters.test.ts @@ -13,7 +13,8 @@ const adapters = [ ["openclaw", "openclaw"], ["opencode", "opencode"], ["pi", "pi"], - ["prime", "prime-agent"] + ["prime", "prime-agent"], + ["asimi", "asimi"] ] as const; describe("tool adapters", () => { diff --git a/tests/asimi.test.ts b/tests/asimi.test.ts new file mode 100644 index 0000000..5e841e0 --- /dev/null +++ b/tests/asimi.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { BASE_URL, PROVIDER_ID, ZRO_ENV_KEY } from "../src/engine/constants.js"; +import { asimiTool, reasoningEffort } from "../src/engine/tools/asimi.js"; +import type { LaunchContext } from "../src/engine/types.js"; +import { TEST_MODELS } from "./fixtures.js"; + +describe("Asimi adapter", () => { + it("launches via provider env conventions without writing config files", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-asimi-home-")); + const ctx = context(home, { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.invalid", + }); + const plan = await asimiTool.launch(ctx); + + expect(plan).toMatchObject({ + tool: "asimi", + label: "Asimi", + command: "asimi", + model: "glm-5.3-flash", + args: ["--model", "glm-5.3-flash", "--provider", PROVIDER_ID, "--print", "hello"], + }); + expect(plan.files).toBeUndefined(); + expect(plan.env).toMatchObject({ + [ZRO_ENV_KEY]: "sk-asimi-secret", + ZRO_BASE_URL: BASE_URL, + ASIMI_PROVIDER: PROVIDER_ID, + ASIMI_MODEL: "glm-5.3-flash", + ASIMI_REASONING_EFFORT: expect.any(String), + }); + expect(Object.values(plan.env ?? {})).not.toContain("collector.invalid"); + }); + + it("maps reasoning levels to asimi's low|medium|high efforts", () => { + for (const model of TEST_MODELS) { + const effort = reasoningEffort(model); + expect(["low", "medium", "high", undefined]).toContain(effort); + } + }); + + it("omits the reasoning effort when no level maps cleanly", () => { + const model = { + ...TEST_MODELS[0], + reasoning: { + defaultLevel: "off", + levels: [{ id: "off", description: "off", piLevel: "off" as const, openCodeOptions: {} }], + }, + }; + expect(reasoningEffort(model)).toBeUndefined(); + const ctx = context(os.tmpdir()); + ctx.models = [model]; + return asimiTool.launch(ctx).then((plan) => { + expect(plan.env?.ASIMI_REASONING_EFFORT).toBeUndefined(); + }); + }); +}); + +function context(homeDir: string, env: NodeJS.ProcessEnv = {}): LaunchContext { + return { + apiKey: "sk-asimi-secret", + apiKeySource: "env", + env, + model: "glm-5.3", + models: TEST_MODELS, + extraArgs: ["--print", "hello"], + homeDir, + cwd: homeDir, + tempDir: path.join(homeDir, "session"), + platform: "linux", + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }; +} From 850f55c9ee87be214916597b760702d78b4efbed Mon Sep 17 00:00:00 2001 From: LeonHibnik Date: Mon, 12 Oct 2026 14:29:14 +0300 Subject: [PATCH 2/4] feat: update Asimi harness configuration and add live report --- .github/workflows/ci.yml | 8 ++++++-- artifacts/zro-live-report.json | 26 ++++++++++++++++++++++++++ tests/asimi.test.ts | 8 ++++---- 3 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 artifacts/zro-live-report.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51f7e23..41172aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,9 +166,13 @@ jobs: - name: Check live API if: >- - (github.event_name == 'pull_request' && + matrix.harness.id != 'hermes' && + matrix.harness.id != 'openclaw' && + matrix.harness.id != 'asimi' && + matrix.harness.id != 'prime' && + ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && inputs.run_live) + (github.event_name == 'workflow_dispatch' && inputs.run_live)) env: ZRO_API_KEY: ${{ secrets.ZRO_API_KEY }} ZRO_REPORT: artifacts/zro-${{ matrix.harness.id }}-${{ matrix.os }}-live.json diff --git a/artifacts/zro-live-report.json b/artifacts/zro-live-report.json new file mode 100644 index 0000000..10f0812 --- /dev/null +++ b/artifacts/zro-live-report.json @@ -0,0 +1,26 @@ +{ + "generatedAt": "2026-10-12T11:03:26.188Z", + "runId": "1791803006188", + "endpoint": "https://zro.moonmath.ai/v1", + "mode": "live-api", + "harnesses": [ + "claude", + "codex", + "grok", + "kilo", + "omp", + "opencode", + "pi", + "hermes", + "openclaw", + "prime", + "asimi" + ], + "clients": { + "claude": "2.1.220 (Claude Code)", + "codex": "codex-cli 0.144.6" + }, + "checks": [], + "ok": false, + "error": "grok version failed to start: spawn grok ENOENT" +} diff --git a/tests/asimi.test.ts b/tests/asimi.test.ts index 5e841e0..a186726 100644 --- a/tests/asimi.test.ts +++ b/tests/asimi.test.ts @@ -20,16 +20,16 @@ describe("Asimi adapter", () => { tool: "asimi", label: "Asimi", command: "asimi", - model: "glm-5.3-flash", - args: ["--model", "glm-5.3-flash", "--provider", PROVIDER_ID, "--print", "hello"], + model: "glm-5.3", + args: ["--model", "glm-5.3", "--provider", PROVIDER_ID, "--print", "hello"], }); expect(plan.files).toBeUndefined(); expect(plan.env).toMatchObject({ [ZRO_ENV_KEY]: "sk-asimi-secret", ZRO_BASE_URL: BASE_URL, ASIMI_PROVIDER: PROVIDER_ID, - ASIMI_MODEL: "glm-5.3-flash", - ASIMI_REASONING_EFFORT: expect.any(String), + ASIMI_MODEL: "glm-5.3", + ASIMI_REASONING_EFFORT: "high", }); expect(Object.values(plan.env ?? {})).not.toContain("collector.invalid"); }); From 28edede0a590f91914f958eebec0d54943585af8 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Thu, 5 Nov 2026 07:10:19 +0000 Subject: [PATCH 3/4] feat: add Asimi support as a new harness --- .github/workflows/ci.yml | 14 +++++- CHANGELOG.md | 11 +++++ README.md | 1 + scripts/ci/check-clients.mjs | 5 ++- scripts/ci/check-live.mjs | 18 +++++++- src/catalog.ts | 3 +- src/engine/constants.ts | 2 +- src/engine/tools/asimi.ts | 83 ++++++++++++++++++++++++++++++++++ src/run.ts | 4 +- tests/adapters.test.ts | 7 +-- tests/asimi.test.ts | 87 ++++++++++++++++++++++++++++++++++++ tests/fixtures.ts | 13 ++++++ tests/install.test.ts | 1 + tests/launch-install.test.ts | 17 ++++++- tests/no-auth.test.ts | 12 +++-- 15 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 src/engine/tools/asimi.ts create mode 100644 tests/asimi.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ef59d..d2e1288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,11 +164,21 @@ jobs: ZRO_REPORT: artifacts/zro-${{ matrix.harness.id }}-${{ matrix.os }}-compatibility.json run: npm run test:clients -- ${{ matrix.harness.id }} + - name: Install Asimi sandbox dependencies + if: matrix.harness.id == 'asimi' && matrix.os == 'ubuntu-latest' + run: | + if ! command -v podman >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y podman + fi + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Check live API if: >- - (github.event_name == 'pull_request' && + (matrix.harness.id != 'asimi' || matrix.os == 'ubuntu-latest') && + ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && inputs.run_live) + (github.event_name == 'workflow_dispatch' && inputs.run_live)) env: ZRO_API_KEY: ${{ secrets.ZRO_API_KEY }} ZRO_REPORT: artifacts/zro-${{ matrix.harness.id }}-${{ matrix.os }}-live.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abff3b..6057e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ Releases still mark tagged builds. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased + +### Added + +- **Asimi support.** [Asimi](https://asimi.dev/), the open-source vi-inspired coding agent, is now a + supported harness (`zro asimi`). Asimi is launched entirely through its provider env conventions + (`OPENAI_API_KEY`/`OPENAI_BASE_URL` with `ASIMI_PROVIDER=openai`), so no config files are written + and the API key never touches disk. Asimi runs its shell in a podman sandbox, so `podman` and + `just` must be on PATH; the launch explains how to install them when they are missing. Install with + `zro install asimi`. + ## [0.4.2] - 2026-10-29 ### Tools diff --git a/README.md b/README.md index 3cbc62c..ceba636 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ unavailable in this mode. | [OpenClaw](https://github.com/openclaw/openclaw) | `zro install openclaw` | Personal AI assistant | | [Pi](https://github.com/earendil-works/pi) | `zro install pi` | Minimal coding agent | | [Prime Agent](https://primeintellect.ai) | `zro install prime` | Prime Intellect's self-improving RLM agent | +| [Asimi](https://asimi.dev/) | `zro install asimi` | Open-source vi-inspired coding agent | Each adapter launches the installed tool as a child process with isolated, Zro-owned configuration. Session files are temporary; Codex App uses a persistent Zro-owned home. diff --git a/scripts/ci/check-clients.mjs b/scripts/ci/check-clients.mjs index 72d5cc6..d04d8da 100644 --- a/scripts/ci/check-clients.mjs +++ b/scripts/ci/check-clients.mjs @@ -14,7 +14,7 @@ if (!apiKey || apiKey === "ci-fake-key") { } const zroBin = process.env.ZRO_BIN || "zro"; const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-models-report.json"); -const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime"]; +const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime", "asimi"]; const requestedHarness = process.argv[2] || "all"; if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) { throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`); @@ -72,7 +72,8 @@ try { pi: ["pi", "--version"], hermes: ["hermes", "--version"], openclaw: ["openclaw", "--version"], - prime: ["prime-agent", "--version"] + prime: ["prime-agent", "--version"], + asimi: ["asimi", "--version"] }; for (const name of harnesses) { const command = clientCommands[name]; diff --git a/scripts/ci/check-live.mjs b/scripts/ci/check-live.mjs index e52201c..5d3a061 100644 --- a/scripts/ci/check-live.mjs +++ b/scripts/ci/check-live.mjs @@ -23,7 +23,7 @@ if (!apiKey || apiKey === "ci-fake-key") { const zroBin = process.env.ZRO_BIN || "zro"; const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-live-report.json"); const runId = (process.env.ZRO_CI_RUN_ID || `${Date.now()}`).replace(/[^A-Za-z0-9_.-]/g, "-"); -const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime"]; +const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime", "asimi"]; const requestedHarness = process.argv[2] || "all"; if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) { throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`); @@ -122,6 +122,7 @@ try { else if (harness === "opencode2") await checkOpenCode2(); else if (harness === "pi") await checkPi(); else if (harness === "prime") await checkPrime(); + else if (harness === "asimi") await checkAsimi(); else if (SKIPPED_LIVE_HARNESSES.has(harness)) skipped(harness); else throw new Error(`No live checks defined for harness "${harness}"`); } @@ -160,7 +161,8 @@ async function collectVersions() { pi: ["pi", "--version"], hermes: ["hermes", "--version"], openclaw: ["openclaw", "--version"], - prime: ["prime-agent", "--version"] + prime: ["prime-agent", "--version"], + asimi: ["asimi", "--version"] }; for (const name of harnesses) { const command = clientCommands[name]; @@ -497,6 +499,18 @@ async function checkPrime() { passed("prime.reasoning.max", { model: liveModel, thinkingContent: max.thinkingContent }); } +// Asimi has no JSON output mode; the probe asserts a plain-text marker. The +// prompt must not invite shell use, so the podman sandbox is never started. +async function checkAsimi() { + const marker = "ZRO_ASIMI_OK"; + const result = await runZro([ + "launch", "asimi", "--model", liveModel, "--", + "-p", `Reply with exactly ${marker}.`, "--max-turns", "1", "--handsoff" + ], "Asimi reply", 180_000); + assert.match(result.stdout, new RegExp(marker)); + passed("asimi.reply", { model: liveModel }); +} + function primeTurn(result, marker, options = {}) { const events = jsonLines(`${result.stdout}\n${result.stderr}`); const completed = events.findLast( diff --git a/src/catalog.ts b/src/catalog.ts index 6a657b4..6236ea0 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -26,7 +26,8 @@ export const TOOLS: readonly ToolDescriptor[] = [ { id: "hermes", name: "Hermes", hint: "Nous Research's agent", aliases: ["h"], executable: "hermes", installer: { url: "https://hermes-agent.nousresearch.com/install.sh", args: ["--skip-setup"], binDir: ".local/bin" } }, { id: "openclaw", name: "OpenClaw", hint: "Personal AI assistant", aliases: ["claw"], executable: "openclaw", package: "openclaw@latest" }, { id: "pi", name: "Pi", hint: "Minimal coding agent", aliases: [], executable: "pi", package: "@earendil-works/pi-coding-agent@latest" }, - { id: "prime", name: "Prime Agent", hint: "Prime Intellect's self-improving RLM agent", aliases: ["pa", "prime-agent"], executable: "prime-agent", installer: { url: "https://app.primeintellect.ai/prime-agent/install.sh", binDir: ".local/bin" } } + { id: "prime", name: "Prime Agent", hint: "Prime Intellect's self-improving RLM agent", aliases: ["pa", "prime-agent"], executable: "prime-agent", installer: { url: "https://app.primeintellect.ai/prime-agent/install.sh", binDir: ".local/bin" } }, + { id: "asimi", name: "Asimi", hint: "Open-source vi-inspired coding agent", aliases: [], executable: "asimi", installer: { url: "https://asimi.dev/installer", binDir: ".local/bin" } } ] as const; const TOOL_LOOKUP = new Map(); diff --git a/src/engine/constants.ts b/src/engine/constants.ts index aa7d1ff..24b1ec1 100644 --- a/src/engine/constants.ts +++ b/src/engine/constants.ts @@ -41,4 +41,4 @@ export function supportsAttachments(model: Pick): boolea return model.modalities.input.some((modality) => modality !== "text"); } -export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "opencode2", "hermes", "openclaw", "pi", "prime"] as const; +export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "opencode2", "hermes", "openclaw", "pi", "prime", "asimi"] as const; diff --git a/src/engine/tools/asimi.ts b/src/engine/tools/asimi.ts new file mode 100644 index 0000000..9d97642 --- /dev/null +++ b/src/engine/tools/asimi.ts @@ -0,0 +1,83 @@ +import { commandExists } from "../../install.js"; +import type { ZroModel } from "../constants.js"; +import type { LaunchContext, ToolModule } from "../types.js"; + +// Asimi talks to any OpenAI-compatible endpoint through its "openai" provider: +// OPENAI_API_KEY provides the key and OPENAI_BASE_URL the endpoint (asimi +// normalizes the base URL itself, stripping the /v1 suffix before its client +// re-appends it). A custom provider id would resolve ZRO_API_KEY/ZRO_BASE_URL +// by convention, but asimi only collects keys from the environment for its +// standard providers, so the openai route is the one that works headless. +// ASIMI_MODEL/ASIMI_REASONING_EFFORT env overrides beat its config file; the +// --model flag beats both. +const ASIMI_PROVIDER = "openai"; +const ASIMI_PROVIDER_ENV_KEY = "ASIMI_PROVIDER"; +const ASIMI_MODEL_ENV_KEY = "ASIMI_MODEL"; +const ASIMI_REASONING_EFFORT_ENV_KEY = "ASIMI_REASONING_EFFORT"; +const OPENAI_API_KEY_ENV_KEY = "OPENAI_API_KEY"; +const OPENAI_BASE_URL_ENV_KEY = "OPENAI_BASE_URL"; + +// Asimi only accepts low|medium|high. Catalog levels outside that set (off, +// minimal, xhigh) map to the nearest accepted effort; this can differ from +// what the server sent, so the env var is omitted entirely when there is no +// sensible mapping rather than guessing. +const EFFORT_BY_PI_LEVEL: Partial> = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "high" +}; + +// Asimi runs the agent shell in a podman sandbox and builds the sandbox image +// through the Justfile its :init command generates, so both must be on PATH. +const SANDBOX_DEPENDENCIES = ["podman", "just"] as const; + +async function ensureSandboxDependencies(ctx: LaunchContext): Promise { + const missing: string[] = []; + for (const dependency of SANDBOX_DEPENDENCIES) { + if (!(await commandExists(dependency, ctx.env, ctx.platform))) missing.push(dependency); + } + if (missing.length > 0) { + throw new Error( + `Asimi needs ${missing.join(" and ")} to run its shell in a podman sandbox. ` + + "Install it (podman: https://podman.io/docs/installation, just: https://github.com/casey/just) " + + "and run zro asimi again." + ); + } +} + +export const asimiTool: ToolModule = { + id: "asimi", + label: "Asimi", + async launch(ctx) { + await ensureSandboxDependencies(ctx); + const model = ctx.models.find((candidate) => candidate.id === ctx.model); + const env: Record = { + [OPENAI_API_KEY_ENV_KEY]: ctx.apiKey, + [OPENAI_BASE_URL_ENV_KEY]: ctx.inferenceBaseUrl, + [ASIMI_PROVIDER_ENV_KEY]: ASIMI_PROVIDER, + [ASIMI_MODEL_ENV_KEY]: ctx.model + }; + const effort = model ? reasoningEffort(model) : undefined; + if (effort) { + env[ASIMI_REASONING_EFFORT_ENV_KEY] = effort; + } + return { + tool: "asimi", + label: "Asimi", + model: ctx.model, + command: "asimi", + args: ["--model", ctx.model, "--provider", ASIMI_PROVIDER, ...ctx.extraArgs], + env, + message: "Launch Asimi with Zro" + }; + } +}; + +export function reasoningEffort(model: ZroModel): string | undefined { + const level = model.reasoning.levels.find( + (candidate) => candidate.id === model.reasoning.defaultLevel + ); + return level ? EFFORT_BY_PI_LEVEL[level.piLevel] : undefined; +} diff --git a/src/run.ts b/src/run.ts index aea4a6a..792b322 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,6 +24,7 @@ import { codexAppTool, codexTool, } from "./engine/tools/codex.js"; +import { asimiTool } from "./engine/tools/asimi.js"; import { grokTool } from "./engine/tools/grok.js"; import { hermesTool } from "./engine/tools/hermes.js"; import { kiloTool } from "./engine/tools/kilo.js"; @@ -62,7 +63,8 @@ const tools: Record = { opencode: opencodeTool, opencode2: opencode2Tool, pi: piTool, - prime: primeTool + prime: primeTool, + asimi: asimiTool }; const PACKAGE_VERSION = ( diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts index 2265038..d2bf0e8 100644 --- a/tests/adapters.test.ts +++ b/tests/adapters.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { run } from "../src/run.js"; -import { catalogFetch } from "./fixtures.js"; +import { catalogFetch, fakeSandboxBinDir } from "./fixtures.js"; const adapters = [ ["codex", "codex"], @@ -14,7 +14,8 @@ const adapters = [ ["opencode", "opencode"], ["opencode2", "opencode2"], ["pi", "pi"], - ["prime", "prime-agent"] + ["prime", "prime-agent"], + ["asimi", "asimi"] ] as const; describe("tool adapters", () => { @@ -27,7 +28,7 @@ describe("tool adapters", () => { stderr: new PassThrough(), homeDir: home, cwd: home, - env: { ZRO_API_KEY: "sk-adapter-secret", XDG_CONFIG_HOME: path.join(home, ".config") }, + env: { ZRO_API_KEY: "sk-adapter-secret", XDG_CONFIG_HOME: path.join(home, ".config"), PATH: await fakeSandboxBinDir() }, platform: "linux", fetch: catalogFetch(503), }); diff --git a/tests/asimi.test.ts b/tests/asimi.test.ts new file mode 100644 index 0000000..0fcec4e --- /dev/null +++ b/tests/asimi.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { asimiTool, reasoningEffort } from "../src/engine/tools/asimi.js"; +import type { LaunchContext } from "../src/engine/types.js"; +import { TEST_MODELS, fakeSandboxBinDir } from "./fixtures.js"; + +describe("Asimi adapter", () => { + it("launches via provider env conventions without writing config files", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-asimi-home-")); + const ctx = context(home, { + PATH: await fakeSandboxBinDir(), + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.invalid", + }); + const plan = await asimiTool.launch(ctx); + + expect(plan).toMatchObject({ + tool: "asimi", + label: "Asimi", + command: "asimi", + model: "glm-5.3", + args: ["--model", "glm-5.3", "--provider", "openai", "--print", "hello"], + }); + expect(plan.files).toBeUndefined(); + expect(plan.env).toMatchObject({ + OPENAI_API_KEY: "sk-asimi-secret", + OPENAI_BASE_URL: "https://zro.moonmath.ai/v1", + ASIMI_PROVIDER: "openai", + ASIMI_MODEL: "glm-5.3", + ASIMI_REASONING_EFFORT: "high", + }); + expect(Object.values(plan.env ?? {})).not.toContain("collector.invalid"); + }); + + it("rejects the launch when podman or just is missing from PATH", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-asimi-home-")); + const ctx = context(home, { PATH: "/nonexistent-zro-asimi" }); + await expect(asimiTool.launch(ctx)).rejects.toThrow(/podman/); + }); + + it("maps reasoning levels to asimi's low|medium|high efforts", () => { + for (const model of TEST_MODELS) { + const effort = reasoningEffort(model); + expect(["low", "medium", "high", undefined]).toContain(effort); + } + }); + + it("omits the reasoning effort when no level maps cleanly", async () => { + const model = { + ...TEST_MODELS[0], + reasoning: { + defaultLevel: "off", + levels: [{ id: "off", description: "off", piLevel: "off" as const, openCodeOptions: {} }], + }, + }; + expect(reasoningEffort(model)).toBeUndefined(); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-asimi-home-")); + const ctx = context(home, { PATH: await fakeSandboxBinDir() }); + ctx.models = [model]; + const plan = await asimiTool.launch(ctx); + expect(plan.env?.ASIMI_REASONING_EFFORT).toBeUndefined(); + }); +}); + +function context(homeDir: string, env: NodeJS.ProcessEnv = {}): LaunchContext { + return { + apiKey: "sk-asimi-secret", + apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, + env, + model: "glm-5.3", + models: TEST_MODELS, + extraArgs: ["--print", "hello"], + homeDir, + cwd: homeDir, + tempDir: path.join(homeDir, "session"), + platform: "linux", + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }; +} diff --git a/tests/fixtures.ts b/tests/fixtures.ts index f67762b..f053732 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { ZroModel, ZroModalities, ZroReasoningConfig } from "../src/engine/constants.js"; export function testModel( @@ -112,3 +115,13 @@ export function catalogFetch(otherStatus = 200) { ? Response.json(TEST_CATALOG_RESPONSE) : new Response(null, { status: otherStatus }); } + +// The asimi adapter requires podman and just on PATH before it builds a plan. +// Returns a temp directory containing fake executables for both. +export async function fakeSandboxBinDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "zro-sandbox-bin-")); + for (const command of ["podman", "just"]) { + await fs.writeFile(path.join(dir, command), "#!/bin/sh\n", { mode: 0o755 }); + } + return dir; +} diff --git a/tests/install.test.ts b/tests/install.test.ts index 9f3a440..4ef0ef8 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -106,6 +106,7 @@ describe("zro install", () => { it.each([ ["hermes", "hermes-agent.nousresearch.com/install.sh", "--skip-setup"], + ["asimi", "https://asimi.dev/installer", ""], ])("uses the official installer for %s", async (tool, url, expectedArg) => { const { spawn, calls } = recorder(); const code = await run(["install", tool], io(spawn)); diff --git a/tests/launch-install.test.ts b/tests/launch-install.test.ts index b356e59..6023abe 100644 --- a/tests/launch-install.test.ts +++ b/tests/launch-install.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { run } from "../src/run.js"; -import { catalogFetch } from "./fixtures.js"; +import { catalogFetch, fakeSandboxBinDir } from "./fixtures.js"; import type { RunIo, SpawnProcess } from "../src/types.js"; function harness(options: { @@ -158,6 +158,21 @@ describe("zro --install", () => { expect(calls.at(-1)?.command).toBe("omp"); }); + it("installs and launches Asimi through its official installer", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-install-asimi-")); + const { spawn, calls } = harness(); + + const code = await run( + ["asimi", "--install", "--api-key", "sk-test"], + launchIo(home, spawn, { PATH: await fakeSandboxBinDir() }), + ); + + expect(code).toBe(0); + expect(calls[0].command).toBe("bash"); + expect(calls[0].args.join(" ")).toContain("https://asimi.dev/installer"); + expect(calls.at(-1)?.command).toBe("asimi"); + }); + it("stops when installation fails", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-install-failure-")); const { spawn, calls } = harness({ installExitCode: 9 }); diff --git a/tests/no-auth.test.ts b/tests/no-auth.test.ts index c5081f6..e5d705e 100644 --- a/tests/no-auth.test.ts +++ b/tests/no-auth.test.ts @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; -import { TEST_CATALOG_RESPONSE, TEST_MODELS } from "./fixtures.js"; +import { TEST_CATALOG_RESPONSE, TEST_MODELS, fakeSandboxBinDir } from "./fixtures.js"; import type { LaunchContext, SpawnOptions, SpawnProcess, ToolModule } from "../src/engine/types.js"; import { claudeTool } from "../src/engine/tools/claude.js"; import { codexAppTool, codexTool } from "../src/engine/tools/codex.js"; @@ -18,6 +18,7 @@ import { opencodeTool } from "../src/engine/tools/opencode.js"; import { opencode2Tool } from "../src/engine/tools/opencode2.js"; import { piTool } from "../src/engine/tools/pi.js"; import { primeTool } from "../src/engine/tools/prime.js"; +import { asimiTool } from "../src/engine/tools/asimi.js"; import { run } from "../src/run.js"; const GATEWAY_ROOT = "https://gateway.example"; @@ -37,6 +38,7 @@ const tools: readonly ToolModule[] = [ opencode2Tool, piTool, primeTool, + asimiTool, ]; describe("no-auth gateway mode", () => { @@ -197,12 +199,14 @@ describe("no-auth gateway mode", () => { "points %s at the gateway and omits the Zro MCP server", async (_id, tool) => { const home = await fs.mkdtemp(path.join(os.tmpdir(), `zro-no-auth-${tool.id}-`)); - const plan = await tool.launch(context(home)); + const plan = await tool.launch(context(home, { PATH: await fakeSandboxBinDir() })); if (tool.id === "claude") { expect(plan.env?.ANTHROPIC_BASE_URL).toBe(GATEWAY_ROOT); expect(plan.env?.ANTHROPIC_AUTH_TOKEN).toBe("zro-no-auth"); expect(plan.args).not.toContain("--mcp-config"); + } else if (tool.id === "asimi") { + expect(plan.env?.OPENAI_API_KEY).toBe("zro-no-auth"); } else { expect(plan.env?.ZRO_API_KEY).toBe("zro-no-auth"); } @@ -216,7 +220,7 @@ describe("no-auth gateway mode", () => { ); }); -function context(homeDir: string): LaunchContext { +function context(homeDir: string, env: NodeJS.ProcessEnv = {}): LaunchContext { return { apiKey: "zro-no-auth", apiKeySource: "none", @@ -224,7 +228,7 @@ function context(homeDir: string): LaunchContext { inferenceBaseUrl: GATEWAY_BASE_URL, mcpUrl: "https://zro.moonmath.ai/mcp/zro", noAuth: true, - env: {}, + env, model: "glm-5.3", models: TEST_MODELS, extraArgs: [], From b427d6bc16ca09d4d7d5e4cbf770db5542aae485 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Thu, 5 Nov 2026 07:46:13 +0000 Subject: [PATCH 4/4] Retrying the Asimi live probe on transient failures --- scripts/ci/check-live.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/ci/check-live.mjs b/scripts/ci/check-live.mjs index 5d3a061..b2b0062 100644 --- a/scripts/ci/check-live.mjs +++ b/scripts/ci/check-live.mjs @@ -503,10 +503,11 @@ async function checkPrime() { // prompt must not invite shell use, so the podman sandbox is never started. async function checkAsimi() { const marker = "ZRO_ASIMI_OK"; - const result = await runZro([ - "launch", "asimi", "--model", liveModel, "--", - "-p", `Reply with exactly ${marker}.`, "--max-turns", "1", "--handsoff" - ], "Asimi reply", 180_000); + const result = await retryProbe("Asimi reply", 3, () => + runZro([ + "launch", "asimi", "--model", liveModel, "--", + "-p", `Reply with exactly ${marker}.`, "--max-turns", "1", "--handsoff" + ], "Asimi reply", 180_000)); assert.match(result.stdout, new RegExp(marker)); passed("asimi.reply", { model: liveModel }); }