diff --git a/README.md b/README.md index 529e36c..1b62ba8 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ For CI and evals, `config.sample.toml` includes `[engines.mock]` so the enforcem ![Identical workers, each under its own light](docs/engines.png) -Ringer ships with three worker lanes: **Codex CLI** is the built-in default, and `config.sample.toml` carries verified engine blocks for **Grok Build CLI** (works as-is once you `grok login`) and **OpenCode + OpenRouter** (one edit: point `bin` at the sandbox wrapper in your clone). Anything else with a headless CLI is a config block away: +Ringer ships with built-in **Codex CLI** and **Cursor Agent** lanes. `config.sample.toml` also carries engine blocks for **Grok Build CLI** and **OpenCode + OpenRouter**. Anything else with a headless CLI is a config block away: ```toml [engines.mymodel] @@ -164,6 +164,18 @@ args_template = ["run", "{spec}", "--dir", "{taskdir}"] Per-task `"engine": "mymodel"` routes work to it — the invariants (stdin closed, process-group kill, executed verification, raw logs) apply to every engine identically. +### Cursor account lane + +Install and authenticate Cursor Agent in the same POSIX environment that runs Ringer: + +```bash +curl https://cursor.com/install -fsS | bash +cursor-agent login +cursor-agent models +``` + +Route a task with `"engine": "cursor"` and an explicit slug from `cursor-agent models`. Ringer invokes Cursor headlessly with stream JSON, an explicit workspace, `--force`, and `--sandbox enabled`. Full access switches to `--sandbox disabled` only when both Ringer gates allow it. A listed slug is not proof of account entitlement, so run a one-task probe before assigning a batch. + ### The universal harness: OpenCode + OpenRouter Unless a model ships its own first-class harness (Codex does), OpenCode is the harness that runs it — one engine block covers every OpenRouter-served model. `config.sample.toml` includes a ready-to-uncomment engine whose `{model}` placeholder is filled per task from the manifest's `"model"` field, with `model_default` as the fallback. The shipped default is OpenRouter's `z-ai/glm-5.2` — roughly $0.74/M input and $2.33/M output (2026-07), about 20-30x cheaper output than frontier coding models; a complete write-code-and-pass-the-check task lands around a penny. diff --git a/config.sample.toml b/config.sample.toml index 1527dea..158899c 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -91,6 +91,14 @@ token_regex = "tokens\\s+used\\s*:?\\s*([0-9][0-9,]*)" # default reads the `model:` header; override it here only if that format changes. model_report_regex = "(?m)^model:[ \\t]*([^ \\t\\r\\n]+)[ \\t]*\\r?$" +# Cursor Agent is built in. Install: curl https://cursor.com/install -fsS | bash +# then `cursor-agent login`. Set each task's model field to an exact slug from +# `cursor-agent models`; listed models can still be unavailable to the account. +# Uncomment only to override the built-in engine. +# [engines.cursor] +# bin = "cursor-agent" +# model_report_aliases = { "Composer 2.5 Fast" = "composer-2.5-fast", "Cursor Grok 4.5 Medium Fast" = "grok-4.5-fast-high" } + # Grok Build CLI (xAI). Install: curl -fsSL https://x.ai/cli/install.sh | bash # (or: npm install -g @xai-official/grok), then `grok login` — OAuth on a # SuperGrok or X Premium Plus plan. Headless mode (-p) runs a full agentic diff --git a/ringer.py b/ringer.py index 0dca41b..4ce087e 100755 --- a/ringer.py +++ b/ringer.py @@ -121,6 +121,9 @@ class EngineConfig: # its own "model" — this is what makes a harness engine (OpenCode) model # agnostic instead of hard-coding one model into the command line. model_default: str = "" + # Canonicalizes display labels emitted by harnesses that do not report the + # same stable slug accepted by their --model option. + model_report_aliases: tuple[tuple[str, str], ...] = () @property def process_name(self) -> str: @@ -891,6 +894,37 @@ def built_in_codex_engine() -> EngineConfig: ) +def built_in_cursor_engine() -> EngineConfig: + resolved = shutil.which("cursor-agent") or "cursor-agent" + return EngineConfig( + name="cursor", + bin=resolved, + args_template=( + "--print", + "--output-format", + "stream-json", + "--trust", + "--workspace", + "{taskdir}", + "--force", + "{access_args}", + "--model", + "{model}", + "{engine_args}", + "{spec}", + ), + full_access_args=("--sandbox", "disabled"), + sandbox_args=("--sandbox", "enabled"), + token_regex=None, + model_report_regex=r'"model"\s*:\s*"([^"]+)"', + model_report_aliases=( + ("Composer 2.5 Fast", "composer-2.5-fast"), + ("Cursor Grok 4.5 Medium Fast", "grok-4.5-fast-high"), + ), + model_default="", + ) + + def load_eval_config(raw: Any, state_dir: Path) -> EvalConfig: if raw is None: raw = {} @@ -927,7 +961,10 @@ def load_hud_port(raw: Any) -> int: def load_engines(raw: Any) -> dict[str, EngineConfig]: - engines: dict[str, EngineConfig] = {DEFAULT_ENGINE_NAME: built_in_codex_engine()} + engines: dict[str, EngineConfig] = { + DEFAULT_ENGINE_NAME: built_in_codex_engine(), + "cursor": built_in_cursor_engine(), + } if raw is None: return engines if not isinstance(raw, dict): @@ -979,6 +1016,25 @@ def load_engines(raw: Any) -> dict[str, EngineConfig]: raise ValueError( f"engines.{clean_name}.model_report_regex must have a capture group" ) + aliases_raw = section.get("model_report_aliases") + if aliases_raw is None: + model_report_aliases = base.model_report_aliases if base else () + else: + if not isinstance(aliases_raw, dict): + raise ValueError( + f"engines.{clean_name}.model_report_aliases must be a TOML table" + ) + alias_items: list[tuple[str, str]] = [] + for reported_raw, canonical_raw in aliases_raw.items(): + reported = str(reported_raw).strip() + canonical = str(canonical_raw).strip() + if not reported or not canonical: + raise ValueError( + f"engines.{clean_name}.model_report_aliases keys and values " + "must not be empty" + ) + alias_items.append((reported, canonical)) + model_report_aliases = tuple(alias_items) model_default = str( section.get("model_default", base.model_default if base else "") ).strip() @@ -991,6 +1047,7 @@ def load_engines(raw: Any) -> dict[str, EngineConfig]: token_regex=token_regex, model_report_regex=model_report_regex, model_default=model_default, + model_report_aliases=model_report_aliases, ) return engines @@ -8460,7 +8517,11 @@ async def _run_worker(self, runtime: TaskRuntime, spec: str, attempt: int) -> Wo self.active_processes.pop(proc.pid, None) output_tail = capture.text() tokens = parse_token_count(output_tail, engine.token_regex) - reported_model = parse_reported_model(output_tail, engine.model_report_regex) + reported_model = parse_reported_model( + output_tail, + engine.model_report_regex, + dict(engine.model_report_aliases), + ) if timed_out: append_text(log_path, f"\n[ringer.py] worker timed out after {runtime.task.timeout_s}s\n") append_text(log_path, f"[ringer.py] attempt {attempt} exited rc={proc.returncode}\n") @@ -8780,14 +8841,20 @@ def parse_token_count(text: str, token_regex: str | None = DEFAULT_TOKEN_REGEX) return int(matches[-1].replace(",", "")) -def parse_reported_model(text: str, model_report_regex: str | None) -> str | None: +def parse_reported_model( + text: str, + model_report_regex: str | None, + model_report_aliases: dict[str, str] | None = None, +) -> str | None: if not model_report_regex: return None match = re.search(model_report_regex, text, flags=re.IGNORECASE) if match is None or match.lastindex is None: return None value = match.group(1).strip() - return value or None + if not value: + return None + return (model_report_aliases or {}).get(value, value) def effective_model_from_command(command: list[str]) -> str: @@ -8912,6 +8979,7 @@ def print_steering_notes(manifest: Manifest, config: AppConfig) -> None: ENGINE_INSTALL_HINTS = { "codex": "install it with `npm install -g @openai/codex` (or `brew install --cask codex`), then run `codex login`", + "cursor": "install it with `curl https://cursor.com/install -fsS | bash`, then run `cursor-agent login`", "opencode": "install it with `curl -fsSL https://opencode.ai/install | bash`, then run `opencode auth login`", } diff --git a/tests/test_cursor_engine.py b/tests/test_cursor_engine.py new file mode 100644 index 0000000..cc47aa4 --- /dev/null +++ b/tests/test_cursor_engine.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import unittest +from pathlib import Path + +from ringer import ( + ENGINE_INSTALL_HINTS, + build_worker_command, + load_engines, + parse_reported_model, +) + + +class CursorEngineContractTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = load_engines(None)["cursor"] + + def test_requires_explicit_model_and_keeps_sandbox_enabled(self) -> None: + self.assertEqual("", self.engine.model_default) + taskdir = Path("/tmp/cursor-task") + command = build_worker_command( + self.engine, + taskdir=taskdir, + spec="write result.txt", + full_access=False, + model="composer-2.5-fast", + ) + self.assertEqual( + [ + self.engine.bin, + "--print", + "--output-format", + "stream-json", + "--trust", + "--workspace", + str(taskdir), + "--force", + "--sandbox", + "enabled", + "--model", + "composer-2.5-fast", + "write result.txt", + ], + command, + ) + + def test_full_access_explicitly_disables_cursor_sandbox(self) -> None: + command = build_worker_command( + self.engine, + taskdir=Path("/tmp/cursor-task"), + spec="write result.txt", + full_access=True, + model="grok-4.5-fast-high", + ) + self.assertIn("disabled", command) + self.assertNotIn("enabled", command) + + def test_stream_model_labels_are_canonicalized_to_request_slugs(self) -> None: + aliases = dict(self.engine.model_report_aliases) + fixtures = { + "composer-2.5-fast": "composer-2.5-fast", + "Composer 2.5 Fast": "composer-2.5-fast", + "Cursor Grok 4.5 Medium Fast": "grok-4.5-fast-high", + } + for reported, expected in fixtures.items(): + with self.subTest(reported=reported): + line = f'{{"type":"system","subtype":"init","model":"{reported}"}}' + self.assertEqual( + expected, + parse_reported_model( + line, + self.engine.model_report_regex, + aliases, + ), + ) + self.assertIn("https://cursor.com/install", ENGINE_INSTALL_HINTS["cursor"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2)