File size: 7,632 Bytes
4e1037f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | """Grok Build CLI provider — subscription-metered frontier slot.
Drives the locally-installed `grok` CLI (Grok Build) in headless single-turn mode
and reads its JSON envelope. Like GraceKelly this is a *subscription* path, not an
API: there is no key and no per-token bill, but there IS a weekly quota that
belongs to the user, so an n=200 run is a deliberate spend, not a free probe.
Three things the 2026-07-14 probe established, all of which shape this module:
- **The catalogue lies.** `grok models` advertises `grok-4.20-0309-reasoning`;
the backend answers `unknown model id` for it. The tier actually serves
`grok-composer-2.5-fast`, which is what `modelUsage` reports. So the default
here is the model that exists, and pinning another one is expected to fail.
- **The agent scaffolding is not removable.** Even with `--system-prompt-override`,
every call carries ~13k tokens of harness (tool definitions, reminders) on top of
the generation prompt. This is an agent CLI wearing an LLM costume; the SQL prompt
still dominates the task, but the comparison against a bare API provider is not
perfectly like-for-like, and the report should say so.
- **The prompt cannot go through argv.** The generation prompt runs ~40k characters,
well past the Windows command-line limit, so it goes to a file and we pass
`--prompt-file`.
The agent is stripped of tools (`--disallowed-tools`, `--no-memory`, `--no-plan`) and
pointed at a scratch cwd, so it cannot wander the repository.
**But it must not be stripped of turns.** `--max-turns 1` looks like the way to make
an agent behave as a text completion, and it is a trap: Grok spends its first turn
announcing what it is about to do ("Проверю, есть ли в workspace база…") and reaching
for a tool. Cutting it there returns that preamble as the answer — `stopReason:
Cancelled`, prose where SQL should be. The first n=200 run scored 47.0% with 31%
invalid SQL and measured this bug, not the model. With four turns it talks past the
preamble and emits a fenced SQL block (`stopReason: EndTurn`), which the pipeline's
extractor reads normally. `--json-schema` does not help: the model ignores it and the
CLI reports `structuredOutputError`.
"""
from __future__ import annotations
import json
import subprocess
import tempfile
import time
from pathlib import Path
from nl_sql.llm.providers.base import (
GenerateRequest,
GenerateResponse,
ProviderError,
)
# `--tools` is an ALLOW-list ("built-in tools to allow"), so the empty string grants
# the model exactly nothing. That is the whole toolbox gone — including `task`, which
# is the one that spawns subagents, and a subagent is a second model burning the same
# weekly quota. Nothing can be spawned because nothing is available to spawn it with.
#
# This replaced a deny-list (`--disallowed-tools bash,edit,…,task,…`), which was worse
# in two ways. It could not run grok-build at all: naming `task` in the deny-list makes
# the CLI refuse to start the session ("agent building failed: Requirements unsatisfied:
# GrokBuild:run_terminal_cmd / auto_background_on_timeout requires enabled_background").
# The message blames the terminal, but bisecting the list shows `task` is the only entry
# that triggers it — removing the subagent tool leaves the model's own tool config in a
# combination its validator rejects. A CLI bug, not a policy.
#
# And it was twice as expensive: a deny-list still ships every tool DEFINITION in the
# system prompt and only forbids the calls. Measured on the same canary: 13368 input
# tokens with the deny-list, 6449 with `--tools ""`. So the "~13k of agent scaffolding
# is unavoidable" note in _NEXT_SESSION §4.2 was wrong — half of it was the tool specs.
_ALLOWED_TOOLS = ""
class GrokCliProvider:
name: str = "grok_cli"
def __init__(
self,
cli_path: str = "grok",
model: str = "grok-composer-2.5-fast",
timeout_seconds: float = 600.0,
max_turns: int = 4,
effort: str | None = None,
) -> None:
self.model = model
# Read by the cache wrapper: same model at a different effort is a
# different experiment and must not replay the other one's answers.
self.effort = effort
self._cli_path = cli_path
self._timeout = timeout_seconds
self._max_turns = max_turns
def _argv(self, prompt_file: Path, req: GenerateRequest) -> list[str]:
argv = [
self._cli_path,
"--prompt-file",
str(prompt_file),
# Until 2026-07-14 this was missing, so `model` only ever labelled the
# report while the CLI silently served its own default. Asking for a
# model and not passing it is how you "measure" one and run another.
"--model",
self.model,
"--output-format",
"json",
"--verbatim",
"--max-turns",
str(self._max_turns),
"--tools",
_ALLOWED_TOOLS,
"--no-memory",
"--no-plan",
"--no-subagents",
"--disable-web-search",
"--permission-mode",
"dontAsk",
]
if self.effort:
argv += ["--reasoning-effort", self.effort]
if req.system:
argv += ["--system-prompt-override", req.system]
return argv
def generate(self, req: GenerateRequest) -> GenerateResponse:
started = time.perf_counter()
with tempfile.TemporaryDirectory(prefix="grok-gen-") as tmp:
prompt_file = Path(tmp) / "prompt.txt"
prompt_file.write_text(req.prompt, encoding="utf-8")
try:
proc = subprocess.run(
self._argv(prompt_file, req),
capture_output=True,
text=True,
encoding="utf-8",
timeout=self._timeout,
cwd=tmp,
check=False,
)
except FileNotFoundError as exc:
raise ProviderError(f"grok CLI not found at {self._cli_path!r}") from exc
except subprocess.TimeoutExpired as exc:
raise ProviderError(
f"grok CLI timed out after {self._timeout}s for model={self.model}"
) from exc
stdout = proc.stdout or ""
try:
# A failed run prints the JSON error envelope and then a plain-text
# `Error: ...` line, so parse the first document rather than the file.
payload, _ = json.JSONDecoder().raw_decode(stdout.lstrip())
except ValueError as exc:
tail = (proc.stderr or stdout).strip()[:300]
raise ProviderError(
f"grok CLI returned no JSON (rc={proc.returncode}): {tail}"
) from exc
if payload.get("type") == "error":
raise ProviderError(f"grok CLI error: {str(payload.get('message'))[:300]}")
usage = payload.get("usage") or {}
model_usage = payload.get("modelUsage") or {}
# modelUsage names the model the tier actually routed to, which is the honest
# label for a report — `--model` is a request, not a guarantee.
served = next(iter(model_usage), self.model)
return GenerateResponse(
text=str(payload.get("text") or ""),
model=served,
input_tokens=int(usage.get("input_tokens") or 0),
output_tokens=int(usage.get("output_tokens") or 0),
latency_ms=(time.perf_counter() - started) * 1000.0,
)
|