from fastcore.test import test_eq, test_failcursor
cursor-agent CLI or SDK — not a completion endpoint but an agent with its own prompt overhead (~16k input tokens/turn).
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 and the calls come back out of the reply through 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() 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() is for.
Going through 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.
norm_cursor_usage
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.
norm_cursor
def norm_cursor(
d, model:NoneType=None
):A cursor-agent JSON result -> a rishi Resp, with <tool_call> tags read out of the text.
cursor_model
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.
sdk_mode
def sdk_mode(
mode
):rishi’s mode in the SDK’s vocabulary, or a ValueError rather than a silently ignored one.
cursor_models
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.
sdk_available
def sdk_available(
api_key:NoneType=None
):Can the Python SDK be used here - installed, with a key to use it with?
cursor_via
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.
cursor_bin
def cursor_bin(
bin:str='cursor-agent'
):Absolute path to the cursor-agent binary, or a FileNotFoundError that says how to get one.
# 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_binCursorChat
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.
CursorChat
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 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 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]'.
CursorChat.close
def close():Let the live agent go; the next turn builds another.
CursorChat.agent
def agent():The live agent, built on first use and kept until something invalidates the conversation.
# 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.
# 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'}
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
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.
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']
# 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