Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions scripts/ci/check-clients.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`);
Expand Down Expand Up @@ -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];
Expand Down
19 changes: 17 additions & 2 deletions scripts/ci/check-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`);
Expand Down Expand Up @@ -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}"`);
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -497,6 +499,19 @@ 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 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 });
}

function primeTurn(result, marker, options = {}) {
const events = jsonLines(`${result.stdout}\n${result.stderr}`);
const completed = events.findLast(
Expand Down
3 changes: 2 additions & 1 deletion src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolId>();
Expand Down
2 changes: 1 addition & 1 deletion src/engine/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ export function supportsAttachments(model: Pick<ZroModel, "modalities">): 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;
83 changes: 83 additions & 0 deletions src/engine/tools/asimi.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ZroModel["reasoning"]["levels"][number]["piLevel"], string>> = {
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<void> {
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<string, string> = {
[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;
}
4 changes: 3 additions & 1 deletion src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,7 +63,8 @@ const tools: Record<ToolId, ToolModule> = {
opencode: opencodeTool,
opencode2: opencode2Tool,
pi: piTool,
prime: primeTool
prime: primeTool,
asimi: asimiTool
};

const PACKAGE_VERSION = (
Expand Down
7 changes: 4 additions & 3 deletions tests/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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", () => {
Expand All @@ -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),
});
Expand Down
87 changes: 87 additions & 0 deletions tests/asimi.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reasoning-effort mapping test can never fail

This test only asserts expect(["low", "medium", "high", undefined]).toContain(effort) for each fixture model, which is satisfied by the return type itself — it passes even if reasoningEffort returned a wrong value or a constant. TEST_MODELS only exercises xhigh (glm-5.3, defaultLevel "max") and high (kimi-k3), so the minimal → low, low → low, and medium → medium rows of EFFORT_BY_PI_LEVEL in src/engine/tools/asimi.ts are never asserted.

Suggested fix: add explicit per-level expectations (e.g. build models with each defaultLevel/piLevel pair and assert the exact mapped effort) so a mapping regression is caught.

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(),
};
}
13 changes: 13 additions & 0 deletions tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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<string> {
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;
}
1 change: 1 addition & 0 deletions tests/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading