# cursor


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

``` python
from fastcore.test import test_eq, test_fail
```

## The wire

One turn is one `cursor-agent` process. There is no system-prompt
channel and no message list, so the whole conversation is rendered into
the single prompt the CLI takes - which is what the other re-sending
backends (llama, remote) do anyway, and what lets eviction and
`reconfigure` behave here exactly as they do everywhere else. The
alternative, `--resume <session_id>`, would keep Cursor’s own
server-side session and let it drift from `chat.hist` the moment
anything edited the history.

Tools go out as tags. The CLI hands back text, never a structured call,
so the schemas go into the prompt through
[`tag_tools_sp`](https://vedicreader.github.io/rishi/core.html#tag_tools_sp)
and the calls come back out of the reply through
[`parse_tool_tags`](https://vedicreader.github.io/rishi/core.html#parse_tool_tags) -
the same protocol `RemoteChat(tool_mode='tags')` uses for a transport
whose tool channel is shut.

The ids themselves come from Cursor, which is the only thing that knows
what a given account can reach -
[`cursor_models()`](https://vedicreader.github.io/rishi/cursor.html#cursor_models)
asks it, through `Cursor.models.list` on the SDK path and
`cursor-agent models` on the CLI one. `CURSOR_MODELS` names them for the
ergonomics, but adding an id to that dict does not make Cursor accept
it, and Cursor changes the list without asking.

The plain names work on both paths - `cursor-agent -p --model grok-4.5`
is accepted even though `cursor-agent models` lists
`cursor-grok-4.5-high` and friends. What differs is how you ask for an
effort level: a parameter on the SDK
(`ModelParameterValue(id='fast', value='true')`), a decorated name or
the bracket form `'claude-opus-4-8[context=1m,effort=high,fast=false]'`
on the CLI. rishi passes the id through verbatim either way, so an id
Cursor does not know fails at the far end rather than here - which is
what
[`cursor_models()`](https://vedicreader.github.io/rishi/cursor.html#cursor_models)
is for.

Going through
[`Chat`](https://vedicreader.github.io/rishi/core.html#chat) needs the
`cursor/` prefix: `claude-opus-5` and `grok-4.5` are also hosted-API
names, so `Chat('grok-4.5')` is `rishi.remote`’s. `CursorChat(grok45)`
has nothing to infer.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L148"
target="_blank" style="float:right; font-size:smaller">source</a>

### norm_cursor_usage

``` python
def norm_cursor_usage(
    u, model:NoneType=None
):
```

*`cursor-agent`’s usage block -\> rishi’s, so a Cursor turn adds up with
a local one.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L137"
target="_blank" style="float:right; font-size:smaller">source</a>

### norm_cursor

``` python
def norm_cursor(
    d, model:NoneType=None
):
```

*A `cursor-agent` JSON result -\> a rishi
[`Resp`](https://vedicreader.github.io/rishi/core.html#resp), with
`<tool_call>` tags read out of the text.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L124"
target="_blank" style="float:right; font-size:smaller">source</a>

### cursor_model

``` python
def cursor_model(
    model, effort:NoneType=None, fast:NoneType=None, via:str='cli'
):
```

*A model id with its effort and speed attached, spelled the way the
active path spells them.*

The SDK carries both as model *parameters* on a `ModelSelection`; the
CLI takes the bracket form (`grok-4.5[effort=high,fast=true]`) it
documents. Same two arguments either way, so a chat that moves between
paths asks for the same thing rather than being rewritten.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L118"
target="_blank" style="float:right; font-size:smaller">source</a>

### sdk_mode

``` python
def sdk_mode(
    mode
):
```

*rishi’s mode in the SDK’s vocabulary, or a `ValueError` rather than a
silently ignored one.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L99"
target="_blank" style="float:right; font-size:smaller">source</a>

### cursor_models

``` python
def cursor_models(
    bin:str='cursor-agent', api_key:NoneType=None, via:NoneType=None
):
```

*The model ids this account can actually reach; Cursor is the source of
truth, not a table in here.*

Asked through whichever credential there is - `Cursor.models.list` when
the SDK has a key, and `cursor-agent models` otherwise. The SDK path has
no business shelling out to a CLI it does not otherwise need, and a
machine with a key but no CLI installed is a perfectly ordinary machine.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L56"
target="_blank" style="float:right; font-size:smaller">source</a>

### sdk_available

``` python
def sdk_available(
    api_key:NoneType=None
):
```

*Can the Python SDK be used here - installed, with a key to use it
with?*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L86"
target="_blank" style="float:right; font-size:smaller">source</a>

### cursor_via

``` python
def cursor_via(
    via:NoneType=None, api_key:NoneType=None
):
```

*Which path to take: what you named, else the SDK when there is a key
and a package for it.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L79"
target="_blank" style="float:right; font-size:smaller">source</a>

### cursor_bin

``` python
def cursor_bin(
    bin:str='cursor-agent'
):
```

*Absolute path to the `cursor-agent` binary, or a `FileNotFoundError`
that says how to get one.*

``` python
# the transcript keeps roles and tool calls, so a re-sent conversation reads as one
hist = [{'role': 'user', 'content': 'add 1 and 2'},
        {'role': 'assistant', 'content': 'on it', 'tool_calls': [ToolCall('add', {'a': 1, 'b': 2})]},
        {'role': 'tool', 'name': 'add', 'content': '3'}]
p = render_prompt(hist, sp='Be terse.')
assert p.startswith('Be terse.\n\n## User\nadd 1 and 2')
assert '<tool_call>' in p and '"name": "add"' in p and '## Tool result (add)\n3' in p
test_eq(render_prompt([], sp='Be terse.'), 'Be terse.')

# a reply is a `Resp` like any other, tags parsed out and usage folded into rishi's shape
r = norm_cursor({'result': 'on it\n<tool_call>{"name": "add", "arguments": {"a": 1}}</tool_call>',
                 'usage': {'inputTokens': 16391, 'outputTokens': 30, 'cacheReadTokens': 896}}, 'grok')
test_eq(resp_text(r), 'on it')
test_eq(r['tool_calls'][0]['function'], {'name': 'add', 'arguments': {'a': 1}})
test_eq(r['usage'], {'prompt_tokens': 16391, 'completion_tokens': 30, 'total_tokens': 16421,
                     'cached_tokens': 896, 'model': 'grok'})
test_fail(lambda: norm_cursor({'is_error': True, 'result': 'not logged in'}), contains='not logged in')


# the named ids are the SDK's: a base model, no `cursor-` prefix and no effort baked into the name
test_eq((grok45, opus5, cursor_default), ('grok-4.5', 'claude-opus-5', 'default'))
assert not any(i.startswith('cursor-') for i in CURSOR_MODELS.values())
assert not any(i.endswith(('-high', '-low', '-medium', '-xhigh', '-max', '-fast')) for i in CURSOR_MODELS.values())
assert len(set(CURSOR_MODELS.values())) == len(CURSOR_MODELS)      # no id named twice

# ...and they route to this backend whichever family they name, prefix or not
for nm in ('grok45', 'opus5', 'gemini31_pro', 'kimi_k3', 'composer25'):
    test_eq(split_runtime(f'cursor/{CURSOR_MODELS[nm]}')[0], 'cursor')
test_eq(infer_runtime('cursor-grok-4.5-high'), 'cursor')           # the CLI dialect still infers


# discovery follows the credential: the SDK when there is a key for it, the CLI otherwise
import cursor_sdk, types
_real_models = cursor_sdk.Cursor.models
try:
    cursor_sdk.Cursor.models = types.SimpleNamespace(
        list=lambda **kw: [types.SimpleNamespace(id='cursor-grok-4.5-high'), types.SimpleNamespace(id='auto')])
    test_eq(cursor_models(via='sdk'), ['cursor-grok-4.5-high', 'auto'])
finally: cursor_sdk.Cursor.models = _real_models

_real_run, _real_bin = subprocess.run, cursor_bin
try:   # the CLI branch parses `id - Label` lines and ignores the tip at the bottom
    cursor_bin = lambda bin=CURSOR_BIN: '/fake/cursor-agent'
    subprocess.run = lambda *a, **kw: types.SimpleNamespace(
        stdout='Available models\n\nauto - Auto (default)\ncursor-grok-4.5-low - Cursor Grok 4.5 Low\n\nTip: use --model\n')
    test_eq(cursor_models(via='cli'), ['auto', 'cursor-grok-4.5-low'])
finally: subprocess.run, cursor_bin = _real_run, _real_bin
```

## CursorChat

Conservative defaults above. Use plain ids from `rishi.cursor`
(`grok45`, `composer25`, …) or `Chat('cursor/grok-4.5')` so routing
doesn’t hit the hosted API.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L156"
target="_blank" style="float:right; font-size:smaller">source</a>

### CursorChat

``` python
def CursorChat(
    model:NoneType=None, runtime:NoneType=None, model_path:NoneType=None, sp:str='', messages:NoneType=None,
    tools:NoneType=None, ctx_limit:NoneType=None, approve:NoneType=None, tool_max_len:NoneType=None,
    max_steps:int=10, parallel_tools:bool=False, max_parallel_tools:NoneType=None,
    final_prompt:str="You've reached the tool-call budget for this turn. Stop calling tools and answer with what you already have.",
    mode:str='ask', # 'ask' (read-only Q&A) or 'plan'; None lets cursor-agent edit and run things
    sandbox:str='enabled', # 'enabled'/'disabled'; None leaves the CLI's own config alone
    trust:bool=False, # trust this workspace without prompting - the CLI refuses an untrusted one
    workspace:NoneType=None, # directory cursor-agent works in; None -> the cwd
    effort:NoneType=None, # 'low'/'medium'/'high'/'xhigh'/'max'; None -> the model's own default
    fast:NoneType=None, # ask for the fast build of the model; None -> the model's own default
    via:NoneType=None, # 'sdk' or 'cli'; None -> the SDK when there is a key and the package
    api_key:NoneType=None, # SDK key; None -> $CURSOR_API_KEY. The CLI path needs none of this
    cursor_tools:NoneType=None, # Cursor's *own* tools, allowlisted; None -> whatever `mode` allows
    cursor_disallowed:tuple=('shell',), # ...and the ones it may never use, whatever `mode` says
    bin:str='cursor-agent', timeout:int=600, cbs:NoneType=None, default_cbs:bool=True
):
```

*Chat against a Cursor CLI model - the same
[`rishi.core.Chat`](https://vedicreader.github.io/rishi/core.html#chat)
API, driving `cursor-agent` headless.*

## The SDK path: one agent, many turns

A `cursor-agent` turn costs about nine seconds, and only three of them
are the model. The other six are the CLI starting up - auth, session,
MCP and plugin loading - and rishi pays it again on every turn because
every turn is a new process. There is no stdio server to hold open: `-p`
reads stdin to EOF, answers once and exits, and the interactive mode is
a TUI.

`cursor-sdk` is the way out. One `Agent` holds the conversation across
`send()` calls, so the startup is paid once per chat instead of once per
turn. It wants its own key - a user key from [the
dashboard](https://cursor.com/dashboard/api) in `$CURSOR_API_KEY`, *not*
the `cursor-agent login` session - so both paths have to stay:
`via=None` takes the SDK when there is a key and the package to use it
with, and the CLI otherwise; `via='sdk'` and `via='cli'` say which
outright. Neither group of users hits a wall.

A live agent has its own memory of the conversation, so only the unsent
tail goes out each turn rather than the whole transcript. That is faster
and cheaper, and it is a lie the moment rishi’s history changes
underneath it - which is exactly what eviction and `reconfigure` do. So
`_recreate_conv`, the hook both of those already call, closes the agent:
the next turn builds a fresh one and re-sends the history as it now
stands. Drift is not managed, it is made impossible.

Needs `pip install 'rishi[cursor]'`.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L340"
target="_blank" style="float:right; font-size:smaller">source</a>

### CursorChat.close

``` python
def close():
```

*Let the live agent go; the next turn builds another.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/cursor.py#L285"
target="_blank" style="float:right; font-size:smaller">source</a>

### CursorChat.agent

``` python
def agent():
```

*The live agent, built on first use and kept until something invalidates
the conversation.*

``` python
# dispatch: the prefix names the runtime, and a `cursor-` id is recognised on its own
import rishi.core, rishi.cursor
test_eq(rishi.core.get_runtime('cursor'), rishi.cursor.CursorChat)
test_eq(split_runtime('cursor/grok-4.5-high'), ('cursor', 'grok-4.5-high'))
test_eq(infer_runtime('grok-4.5-high'), 'cursor')
test_eq(infer_runtime('grok-4'), 'cursor')                # a plain grok is still somebody else's API
test_eq(type(Chat.__new__(Chat, 'cursor/grok-4.5-low')), rishi.cursor.CursorChat)

# it is hosted, whatever the local binary suggests
test_eq(rishi.cursor.CursorChat.local, False)


# a plain Cursor id is also a hosted-API name, so `Chat` needs telling; only the decorated ones infer
test_eq(infer_runtime('grok-4.5'), 'cursor')
test_eq(type(Chat.__new__(Chat, f'cursor/{rishi.cursor.grok45}')), rishi.cursor.CursorChat)
```

## Against the real CLI / SDK

Live cells need `cursor-agent login` (CLI) or `$CURSOR_API_KEY` (SDK).
SDK keeps one agent alive across turns; CLI pays process startup each
call.

``` python
# The static aliases are useful before login; `cursor_models()` below checks what this account can reach.
CURSOR_MODELS
```

    {'cursor_default': 'default',
     'grok45': 'grok-4.5',
     'composer25': 'composer-2.5',
     'opus5': 'claude-opus-5',
     'opus48': 'claude-opus-4-8',
     'opus47': 'claude-opus-4-7',
     'opus46': 'claude-opus-4-6',
     'opus45': 'claude-opus-4-5',
     'fable5': 'claude-fable-5',
     'sonnet5': 'claude-sonnet-5',
     'sonnet46': 'claude-sonnet-4-6',
     'sonnet45': 'claude-sonnet-4-5',
     'sonnet4': 'claude-sonnet-4',
     'haiku45': 'claude-haiku-4-5',
     'gpt56_sol': 'gpt-5.6-sol',
     'gpt56_terra': 'gpt-5.6-terra',
     'gpt56_luna': 'gpt-5.6-luna',
     'gpt55': 'gpt-5.5',
     'gpt54': 'gpt-5.4',
     'gpt54_mini': 'gpt-5.4-mini',
     'gpt54_nano': 'gpt-5.4-nano',
     'gpt53_codex': 'gpt-5.3-codex',
     'gpt52': 'gpt-5.2',
     'gpt51': 'gpt-5.1',
     'gpt5_mini': 'gpt-5-mini',
     'gemini36_flash': 'gemini-3.6-flash',
     'gemini35_flash': 'gemini-3.5-flash',
     'gemini31_pro': 'gemini-3.1-pro',
     'gemini3_flash': 'gemini-3-flash',
     'gemini25_flash': 'gemini-2.5-flash',
     'kimi_k3': 'kimi-k3',
     'kimi_k27_code': 'kimi-k2.7-code',
     'glm52': 'glm-5.2'}

``` python
chat = CursorChat(gemini35_flash, trust=True)
r = chat('In one sentence: what is a Kalman filter?')
print(resp_text(r))
print(chat.use)
```

    A Kalman filter is an optimal estimation algorithm that recursively estimates the true, hidden state of a dynamic system over time by combining a mathematical model of the system's behavior with a sequence of noisy, uncertain measurements.
    total=13,626|in=13,584|out=42|turns=1|model=gemini-3.5-flash

``` python
for chunk in chat('And in one more sentence, where would I not use one?', stream=True): print(chunk, end='')
```

    > **🧠 Thinking**
    >
    > **Clarifying Kalman Filter Use**
    > 
    > I'm focusing on concisely answering your question about when *not* to use a Kalman filter. My current thinking is to highlight scenarios where linearity and Gaussian assumptions are significantly violated, making alternative methods more suitable.
    > 
    > **Refining Exclusion Criteria**
    > 
    > I'm refining the exclusion criteria for Kalman filters. My focus is on pinpointing situations where non-linearities or non-Gaussian noise fundamentally break the standard assumptions, making other estimation techniques clearly superior.
    > 
    > 

    You would not use a Kalman filter in systems with highly non-linear behavior and non-Gaussian noise where particle filters or deep learning models are more appropriate, or in simple applications where a basic moving average or low-pass filter is sufficient and computationally cheaper.

``` python
def add(a: int, b: int) -> int:
    "Add a and b."
    return a + b

tchat = CursorChat(grok45, tools=[add], trust=True)
print(resp_text(tchat('What is 17 plus 25? Use the tool.')))
print([m['content'] for m in tchat.hist if m['role'] == 'tool'])
```

    17 plus 25 is **42**.
    ['42']

``` python
# The SDK path, and the reason for it: turn 2 skips the startup the CLI pays on every turn.
import time
sdk_chat = CursorChat(grok45, via='sdk')
for q in ['My favourite number is 7. Reply with exactly: ok', 'What is my favourite number? Digits only.']:
    s = time.time(); print(f'{resp_text(sdk_chat(q))!r}  {time.time()-s:.1f}s')
sdk_chat.close()
```

    'ok'  2.4s
    '7'  2.1s
