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,
        )