diff --git a/README.md b/README.md index c4f1412..dca5b42 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Each chat is one ongoing conversation that survives restarts. Send `/new` to sta The Telegram bot shows a **typing indicator** while processing, plus a silent "Working… step N" message that follows the agent's tool calls (searching, saving, reminders) and disappears once the reply arrives. -Commands: `/new` starts a fresh conversation, `/model` shows the available models as buttons (tap one to switch, or send `/model `), and `/effort` does the same for the reasoning effort. Model and effort changes are saved to `~/.memclaw/.env` and keep the current conversation. They're available with the Claude backend. +Commands: `/new` starts a fresh conversation, `/model` shows the available models as buttons (tap one to switch, or send `/model `), and `/effort` does the same for the reasoning effort. Model and effort changes are saved to `~/.memclaw/.env` and keep the current conversation. With Cursor, `/effort` only applies to models that expose a reasoning setting. #### Setup @@ -222,6 +222,7 @@ Each chat keeps one Claude Code process running and reuses it, so replies don't - The wizard prompts for `CURSOR_API_KEY` and an optional `CURSOR_MODEL` (defaults to `composer-2.5`). - Memclaw tools are exposed to the Cursor agent via a long-lived local HTTP MCP server on `127.0.0.1` (default port `17373`, override with `MEMCLAW_MCP_PORT`). - Requests bill per token against your Cursor plan (or pay-as-you-go overage rates once your plan limit is reached), and the cost is logged with each turn. +- One Cursor SDK bridge process serves all chats, and each chat keeps one local Cursor agent open across turns. The agent stores its conversation under `~/.cursor` and is resumed after a restart. ## How It Works @@ -362,7 +363,8 @@ Backend choice and credentials are covered above in [Agent Backend](#agent-backe | `AGENT_BACKEND` | Optional | Agent SDK to use (defaults to `claude`; set to `cursor` for Cursor SDK) | | `MEMCLAW_PLATFORM` | Optional | Front-end the bare `memclaw` launches: `telegram`, `whatsapp`, `slack`, or `terminal` (defaults to `terminal`) | | `CURSOR_API_KEY` | For Cursor backend | Cursor API key from Dashboard → Integrations | -| `CURSOR_MODEL` | For Cursor backend | Cursor model name (defaults to `composer-2.5`) | +| `CURSOR_MODEL` | For Cursor backend | Cursor model name (defaults to `composer-2.5`; also set by `/model`) | +| `CURSOR_EFFORT` | Optional | Cursor reasoning level, for models that have one (set by `/effort`) | | `MEMCLAW_MCP_PORT` | For Cursor backend | Local MCP HTTP port (defaults to `17373`) | | `TELEGRAM_BOT_TOKEN` | For Telegram bot | Your Telegram bot token | | `ALLOWED_USER_IDS` | For Telegram bot | Comma-separated Telegram user IDs | diff --git a/docs/docker.md b/docs/docker.md index 2bd5b8c..5d1fc8a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -123,13 +123,16 @@ docker compose up -d --build grows with your memory vault, so back the volume up periodically. - The Claude backend (`AGENT_BACKEND=claude`, the default) keeps one Claude Code CLI process running per active chat (closed on `/new` - and shutdown). The Cursor backend (`AGENT_BACKEND=cursor`) is - pure-Python and slightly leaner if you don't need Claude. + and shutdown). The Cursor backend (`AGENT_BACKEND=cursor`) runs a + single Cursor SDK bridge process (Node) shared by all chats. - Conversations resume after a restart: `sessions.json` lives in the data volume and Claude Code's own session files in the container's `~/.claude`. That directory isn't on the volume, so after a rebuild (`docker compose up -d --build` recreates the container) each chat - starts a fresh conversation. Memories are unaffected. + starts a fresh conversation. Memories are unaffected. The same goes + for the Cursor backend, whose agents are stored in the container's + `~/.cursor`. - Telegram's `/model` and `/effort` save their choice to - `~/.memclaw/.env`. A `CLAUDE_MODEL` / `CLAUDE_EFFORT` passed in from - the compose `.env` wins over that file on the next start. + `~/.memclaw/.env`. A `CLAUDE_MODEL` / `CLAUDE_EFFORT` (or + `CURSOR_MODEL` / `CURSOR_EFFORT`) passed in from the compose `.env` + wins over that file on the next start. diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md index 575d8c9..2ec913b 100644 --- a/docs/memory-architecture.md +++ b/docs/memory-architecture.md @@ -10,17 +10,18 @@ Short-term memory is the conversation itself: what you and the agent said in the ### Conversation Sessions -Each chat's conversation lives in the agent backend's own session (for Claude, a Claude Code session), not in a buffer Memclaw re-sends with every message. +Each chat's conversation lives in the agent backend's own session (for Claude, a Claude Code session; for Cursor, a local Cursor SDK agent), not in a buffer Memclaw re-sends with every message. **How it works:** - Every chat gets a session key: `:` for the bots (e.g. `telegram:12345`, `slack:C01ABC`), `cli` for the interactive terminal. - The Claude backend keeps one connected Claude CLI process per chat and reuses it for every turn, so a message doesn't pay the CLI start-up cost. Turns in the same chat are serialized. +- The Cursor backend runs one Cursor SDK bridge process for all chats and keeps one open agent per chat. The agent stores its conversation on disk (under `~/.cursor`, in a store tied to the bridge's working directory, which is pinned to the memory directory). Cursor agents have no system-prompt field, so the system prompt goes at the top of a new agent's first message; later messages carry only the context block and the message. - The session id is saved in `~/.memclaw/sessions.json` (written atomically). After a restart, the next message in that chat resumes the same session, so the conversation carries on. - If a session can't be resumed (deleted, created on another machine, ...), a warning is logged and a fresh session starts. -- Each saved session records a **fingerprint** of the code-level instructions (the system-prompt template and the backend). Claude Code keeps a session's original system prompt when it resumes, so a session created under different instructions (e.g. after an upgrade that changed the template) is not resumed; the chat starts fresh. +- Each saved session records a **fingerprint** of the code-level instructions (the system-prompt template and the backend). A resumed session keeps its original system prompt, so a session created under different instructions (e.g. after an upgrade that changed the template) is not resumed; the chat starts fresh. - `/new` (Telegram command; a message of exactly `/new` in Slack, WhatsApp and the terminal) closes that chat's session and forgets its id. Your memories are untouched. -- `/model` and `/effort` (Telegram) switch the model or effort level. Live sessions reconnect on their next message, resuming the same session with the new settings, so the conversation is kept. +- `/model` and `/effort` (Telegram) switch the model or effort level, and the conversation is kept. Claude sessions reconnect on their next message, resuming the same session with the new settings; Cursor passes the model with every message, so its agents just use it. For Cursor, `/effort` is available only on models that expose a reasoning/effort parameter. **What this means in practice:** @@ -31,9 +32,7 @@ You: What's my dog called? Assistant: Your dog's name is Max. ``` -The conversation is bounded only by the backend's own context management (Claude Code compacts long sessions on its own). Use `/new` to start over. - -The Cursor backend has no native sessions yet: as an interim measure it keeps the last 10 messages per chat in memory and folds them into each prompt. That transcript does not survive a restart. +The conversation is bounded only by the backend's own context management (e.g. Claude Code compacts long sessions on its own). Use `/new` to start over. ### System Prompt and Per-Message Context @@ -56,7 +55,7 @@ The block stays in the session transcript, which is why it is kept small. The session stores a hash of the AGENTS.md and MEMORY.md content it has seen (persisted in `sessions.json`). Before each message the files are hashed again; if either changed (an `update_instructions` call, a consolidation, a manual edit), its new content is included **once** in that message's context block, labelled as superseding the earlier version, and the stored hash is updated. The conversation continues without a reset. Changes the session makes itself during a turn (`memory_save` with `permanent=true`, `update_instructions`) count as seen and aren't sent back; a consolidation that rewrites MEMORY.md during a turn is. -Because Claude Code keeps a resumed session's original system prompt, the stored hashes always describe what the session has actually seen, including across restarts. +Because a resumed session keeps its original system prompt, the stored hashes always describe what the session has actually seen, including across restarts. ## Long-Term Memory diff --git a/memclaw/backends/claude.py b/memclaw/backends/claude.py index b56bc6e..470d48b 100644 --- a/memclaw/backends/claude.py +++ b/memclaw/backends/claude.py @@ -35,6 +35,7 @@ from .base import ProgressCallback, TurnResult, report_tool_step from .claude_models import SDK_EFFORT_LEVELS, ModelInfo, fetch_models from .mcp_tools import MCP_SERVER_NAME +from .model_selection import ModelSelectionMixin if TYPE_CHECKING: from rich.console import Console @@ -253,9 +254,11 @@ class _Session: options_key: tuple[str, str | None] | None = None -class ClaudeAgentBackend: +class ClaudeAgentBackend(ModelSelectionMixin): """Claude Agent SDK implementation of the AgentBackend protocol.""" + _label = "Claude" + _env_prefix = "CLAUDE" name: ClassVar[str] = "claude" display_name: ClassVar[str] = "Claude Agent SDK" supports_model_selection: ClassVar[bool] = True @@ -561,55 +564,26 @@ async def reset_session(self, session_key: str) -> None: # -- Model selection --------------------------------------------------- - @property - def model(self) -> str: - return self._model - - @property - def effort(self) -> str | None: - return self._effort - async def list_models(self) -> list[ModelInfo]: self._models = await fetch_models(_claude_auth_mode(self.config), _credential(self.config)) return self._models - async def _model_info(self, model_id: str) -> ModelInfo | None: - """*model_id*'s entry in the model list, or None if it isn't listed - or the list can't be fetched.""" - if not self._models: - try: - await self.list_models() - except Exception as exc: - logger.warning("Could not fetch the Claude model list: {exc}", exc=exc) - return None - return next((m for m in self._models if m.id == model_id), None) - - async def effort_levels(self) -> list[str] | None: - info = await self._model_info(self._model) - return list(info.effort_levels) if info else None - async def set_model(self, model_id: str) -> str: - model_id = model_id.strip() - info = await self._model_info(model_id) - if info is None and not self._models: - # Can't check the id, and a typo would break every turn. - raise ValueError("Couldn't load the model list; try again later.") - if info is None: - raise ValueError(f"Unknown model '{model_id}'.") - effort = self._effort + """Switch from the next turn on. Live chats then reconnect, resuming + their session (Claude Code resumes fine under a different model).""" + info = await self._known_model(model_id.strip()) + # Keep the effort if the new model takes it, otherwise the nearest + # level it does take (or none at all). + effort = _fit_effort(info.effort_levels, self._effort or _DEFAULT_EFFORT) note = "" - if info is not None: - # Keep the effort if the new model takes it, otherwise the nearest - # level it does take (or none at all). - effort = _fit_effort(info.effort_levels, self._effort or _DEFAULT_EFFORT) - if self._effort and effort is None: - note = f"{info.display_name} doesn't support an effort setting, so none is used." - elif self._effort and effort != self._effort: - note = ( - f"{info.display_name} doesn't support '{self._effort}' effort; " - f"using '{effort}' instead." - ) - self._apply_model(model_id, effort) + if self._effort and effort is None: + note = f"{info.display_name} doesn't support an effort setting, so none is used." + elif self._effort and effort != self._effort: + note = ( + f"{info.display_name} doesn't support '{self._effort}' effort; " + f"using '{effort}' instead." + ) + self._apply_model(info.id, effort) return note async def set_effort(self, level: str) -> None: @@ -628,24 +602,6 @@ async def set_effort(self, level: str) -> None: ) self._apply_model(self._model, level) - def _apply_model(self, model: str, effort: str | None) -> None: - """Use *model* / *effort* from the next turn on and save them to - ~/.memclaw/.env. Live chats reconnect on their next turn, resuming - their session (Claude Code resumes fine under a different model).""" - from ..setup import update_env_file # local import — setup imports backends - - logger.info( - "Claude model: {m} effort: {e} -> {m2} effort: {e2}", - m=self._model, e=self._effort, m2=model, e2=effort, - ) - self._model, self._effort = model, effort - self.config.claude_model = model - self.config.claude_effort = effort or "" - if effort: - update_env_file({"CLAUDE_MODEL": model, "CLAUDE_EFFORT": effort}) - else: - update_env_file({"CLAUDE_MODEL": model}, drop_keys=["CLAUDE_EFFORT"]) - # -- Runtime --------------------------------------------------------- async def run_one_shot( diff --git a/memclaw/backends/cursor.py b/memclaw/backends/cursor.py index 3e081e3..a4b52fa 100644 --- a/memclaw/backends/cursor.py +++ b/memclaw/backends/cursor.py @@ -1,18 +1,29 @@ -"""Cursor Python SDK agent backend.""" +"""Cursor Python SDK agent backend. + +One `cursor-sdk-bridge` process (a Node subprocess) serves the whole Memclaw +process. Each chat keeps one open local agent on it, reused across turns: the +agent stores its own conversation on disk, so follow-up messages only carry +the per-turn context and the new message, and a restarted process resumes the +agent by id. +""" from __future__ import annotations +import asyncio import os -from collections import deque +from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Literal +import httpx from loguru import logger from .base import ProgressCallback, TurnResult +from .claude_models import ModelInfo from .cursor_hooks import cursor_hooks_status, ensure_cursor_hooks from .cursor_sdk_adapter import RunUsageTracker, collect_run_result, extract_run_text from .mcp_bridge import HttpMcpServer, mcp_servers_for +from .model_selection import ModelSelectionMixin if TYPE_CHECKING: from rich.console import Console @@ -30,11 +41,10 @@ # Scrub Claude credentials when switching to Cursor so they can't shadow selection. _DROP_KEYS = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] -# INTERIM: the Cursor backend has no native conversation sessions yet, so it -# keeps the last few messages per session key in memory and folds them into -# each prompt. To be replaced by native Cursor conversation handling. -_TRANSCRIPT_MESSAGES = 10 -_TRANSCRIPT_MESSAGE_CHARS = 1000 +# A model parameter whose id contains one of these is treated as its effort +# setting (/effort). Cursor reports parameters per model, so there is no +# fixed list of levels. +_EFFORT_PARAM_HINTS = ("effort", "reasoning") def _cursor_api_key(config: "MemclawConfig") -> str: @@ -46,6 +56,11 @@ def _cursor_model(config: "MemclawConfig") -> str: return model or _DEFAULT_MODEL +def _cursor_effort(config: "MemclawConfig") -> str | None: + effort = (config.cursor_effort or os.environ.get("CURSOR_EFFORT", "")).strip() + return effort or None + + def _apply_cost_fallback(result: TurnResult, *, bills_per_token: bool) -> None: if result.cost_usd is not None or not bills_per_token: return @@ -71,31 +86,23 @@ def _build_combined_prompt(*, system_prompt: str, user_message: str) -> str: ) -def _format_transcript(transcript: list[tuple[str, str]]) -> str: - """INTERIM: render recent (role, text) messages for the prompt.""" - if not transcript: - return "" - lines = ["=== RECENT CONVERSATION ==="] - for role, text in transcript: - lines.append(f"{'User' if role == 'user' else 'Assistant'}: {text}") - return "\n".join(lines) - - def _build_user_message( *, system_prompt: str, user_message: str, context: str = "", - transcript: list[tuple[str, str]] | None = None, image_b64: str | None = None, image_media_type: str = "image/jpeg", ) -> str | Any: + """The message to send. Cursor agents have no system prompt field, so a + new agent gets it at the top of its first message; follow-ups pass "".""" from cursor_sdk import SDKImage, UserMessage - parts = [_format_transcript(transcript or []), context, user_message] - prompt = _build_combined_prompt( - system_prompt=system_prompt, - user_message="\n\n".join(p for p in parts if p), + body = "\n\n".join(p for p in (context, user_message) if p) + prompt = ( + _build_combined_prompt(system_prompt=system_prompt, user_message=body) + if system_prompt + else body ) if not image_b64: return prompt @@ -119,7 +126,7 @@ def _agent_options( *, api_key: str, cwd: str, - model: str, + model: Any, mcp_servers: dict[str, Any] | None = None, ) -> Any: from cursor_sdk import AgentOptions @@ -132,23 +139,72 @@ def _agent_options( ) -class CursorAgentBackend: +def _effort_param(model: Any) -> Any | None: + """The parameter of an SDKModel that acts as its effort level, if any.""" + for param in model.parameters: + if param.values and any(h in param.id.lower() for h in _EFFORT_PARAM_HINTS): + return param + return None + + +async def _drain_stderr(stream: asyncio.StreamReader) -> None: + """Keep reading the bridge's stderr. The SDK only reads it until the + bridge is up, and a long-lived bridge would block once the pipe fills. + Read in chunks: readline() fails on a line over 64 KiB, which would end + the drain.""" + while chunk := await stream.read(65536): + logger.debug("cursor-sdk-bridge: {out}", out=chunk.decode(errors="replace").rstrip()) + + +def _bridge_failed(exc: BaseException) -> bool: + """True when *exc* came from not reaching the bridge process at all + (it crashed), rather than from the agent or Cursor's API. A timeout + doesn't count: a slow run says nothing about the bridge, and dropping it + would abort other chats' runs.""" + cause = exc.__cause__ + return isinstance(cause, httpx.RequestError) and not isinstance(cause, httpx.TimeoutException) + + +def _bridge_process(client: Any) -> asyncio.subprocess.Process | None: + # The SDK keeps the subprocess it launched on a private attribute. + return getattr(getattr(client, "_owned_bridge", None), "process", None) + + +@dataclass +class _Session: + """One chat's open Cursor agent.""" + + # Serializes turns (and /new) within one chat. + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + agent: Any = None + # The bridge client *agent* lives on; a relaunched bridge doesn't know it. + client: Any = None + + +class CursorAgentBackend(ModelSelectionMixin): """Cursor Python SDK implementation of the AgentBackend protocol.""" + _label = "Cursor" + _env_prefix = "CURSOR" name: ClassVar[str] = "cursor" display_name: ClassVar[str] = "Cursor SDK" - supports_model_selection: ClassVar[bool] = False + supports_model_selection: ClassVar[bool] = True def __init__(self, config: "MemclawConfig") -> None: self.config = config self._api_key = _cursor_api_key(config) self._model = _cursor_model(config) + self._effort = _cursor_effort(config) self._cwd = str(config.memory_dir) os.environ["MEMCLAW_MEMORY_DIR"] = self._cwd self.bills_per_token = True self._mcp_server = HttpMcpServer() - # INTERIM: session key -> recent (role, text) messages. - self._transcripts: dict[str, deque[tuple[str, str]]] = {} + self._client: Any = None # the shared bridge client, launched lazily + self._client_lock = asyncio.Lock() + self._background: set[asyncio.Task] = set() # stderr drains + self._sessions: dict[str, _Session] = {} + self._models: list[ModelInfo] = [] # filled by list_models() + self._effort_params: dict[str, str] = {} # model id -> effort param id @classmethod def is_configured(cls, config: "MemclawConfig") -> bool: @@ -169,7 +225,10 @@ def configuration_help(cls) -> str: @classmethod def status_rows(cls, config: "MemclawConfig") -> list[tuple[str, str]]: - return [("Model", _cursor_model(config))] + return [ + ("Model", _cursor_model(config)), + ("Effort", _cursor_effort(config) or "default"), + ] @classmethod def wizard_setup( @@ -189,6 +248,7 @@ def wizard_setup( raise SystemExit(1) values: dict[str, str] = {"CURSOR_API_KEY": value} + drop_keys = list(_DROP_KEYS) model_current = existing.get("CURSOR_MODEL", os.environ.get("CURSOR_MODEL", "")) from rich.prompt import Prompt @@ -200,6 +260,9 @@ def wizard_setup( ) if model_answer.strip(): values["CURSOR_MODEL"] = model_answer.strip() + if model_answer.strip() != model_current: + # An effort level picked with /effort belongs to the old model. + drop_keys.append("CURSOR_EFFORT") elif model_current: values["CURSOR_MODEL"] = model_current @@ -214,7 +277,9 @@ def wizard_setup( status=status, ) - return values, list(_DROP_KEYS) + return values, drop_keys + + # -- Lifecycle --------------------------------------------------------- async def on_agent_start(self, tool_executor: "ToolExecutor") -> None: await self._ensure_mcp_server(tool_executor) @@ -230,6 +295,12 @@ async def on_agent_start(self, tool_executor: "ToolExecutor") -> None: ) async def on_agent_shutdown(self) -> None: + # A copy: a message arriving mid-shutdown may add a session. + for session in list(self._sessions.values()): + await self._close_agent(session) + self._sessions.clear() + if self._client is not None: + await self._drop_client(self._client) await self._mcp_server.stop() async def _ensure_mcp_server(self, tool_executor: "ToolExecutor") -> None: @@ -238,46 +309,211 @@ async def _ensure_mcp_server(self, tool_executor: "ToolExecutor") -> None: port=self.config.mcp_http_port, ) - async def reset_session(self, session_key: str) -> None: - self._transcripts.pop(session_key, None) + # -- Bridge ------------------------------------------------------------ - def _remember(self, session_key: str, role: str, text: str) -> None: - transcript = self._transcripts.setdefault( - session_key, deque(maxlen=_TRANSCRIPT_MESSAGES), + async def _launch_client(self) -> Any: + from cursor_sdk import AsyncClient + from cursor_sdk._vendor import resolve_bridge_path + + # Local agents are stored under a path derived from the bridge + # process's working directory (not LocalAgentOptions.cwd), and + # launch_bridge can't set it, so start the bridge through `sh` in + # the memory dir. A restarted process then finds its agents again. + Path(self._cwd).mkdir(parents=True, exist_ok=True) + client = await AsyncClient.launch_bridge( + ["/bin/sh", "-c", 'cd "$0" && exec "$@"', self._cwd, resolve_bridge_path()], + workspace=self._cwd, ) - transcript.append((role, text[:_TRANSCRIPT_MESSAGE_CHARS])) + process = _bridge_process(client) + if process is not None and process.stderr is not None: + task = asyncio.create_task(_drain_stderr(process.stderr)) + self._background.add(task) + task.add_done_callback(self._background.discard) + return client + + async def _get_client(self) -> Any: + async with self._client_lock: + client = self._client + if client is not None: + process = _bridge_process(client) + if process is not None and process.returncode is not None: + # The bridge died between calls: relaunch now rather than + # failing the next message (or /model) first. + logger.warning("Cursor SDK bridge exited ({rc}); relaunching", rc=process.returncode) + await self._drop_client(client) + if self._client is None: + self._client = await self._launch_client() + return self._client + + async def _drop_client(self, client: Any) -> None: + """Shut *client*'s bridge down; the next call launches a new one.""" + if self._client is client: + self._client = None + try: + await client.aclose() + except Exception as exc: + logger.warning("Error while closing the Cursor SDK bridge: {exc}", exc=exc) - # Model selection isn't supported for Cursor yet (see - # supports_model_selection); CURSOR_MODEL picks the model. + # -- Sessions ---------------------------------------------------------- - @property - def model(self) -> str: - return self._model + @staticmethod + async def _close_agent(session: _Session) -> None: + """Release the agent's executor. Its conversation stays on disk.""" + agent, session.agent, session.client = session.agent, None, None + if agent is None: + return + try: + await agent.close() + except Exception as exc: + logger.warning("Error while closing Cursor agent: {exc}", exc=exc) + + async def _open_agent( + self, client: Any, *, resume: str | None, mcp_servers: dict[str, Any], + ) -> tuple[Any, bool]: + """Resume agent *resume*, or create a new one when there is none or it + can't be resumed (deleted, from another machine, ...). Returns the + agent and whether it is new.""" + from cursor_sdk import CursorAgentError + + # Local resume doesn't restore the model; pass it along explicitly. + options = _agent_options( + api_key=self._api_key, + cwd=self._cwd, + model=await self._model_selection(), + mcp_servers=mcp_servers, + ) + if resume: + try: + return await client.agents.resume(resume, options), False + except CursorAgentError as exc: + if _bridge_failed(exc): + raise + logger.warning( + "Could not resume Cursor agent {id} ({exc}); starting a fresh one", + id=resume, exc=exc.message, + ) + return await client.agents.create(options), True + + async def _ensure_agent( + self, session: _Session, client: Any, *, resume: str | None, mcp_servers: dict[str, Any], + ) -> Literal["open", "resumed", "created"]: + """Give *session* an open agent on *client*: the one it already has, + else agent *resume* resumed, else a new one. Returns which.""" + if session.agent is not None and session.client is client: + return "open" + # None yet, or it lived on a bridge that has since been relaunched. + session.agent, is_new = await self._open_agent( + client, resume=resume, mcp_servers=mcp_servers, + ) + session.client = client + return "created" if is_new else "resumed" - @property - def effort(self) -> str | None: - return None + async def _send( + self, + agent: Any, + message: Any, + *, + mcp_servers: dict[str, Any], + force: bool, + max_turns: int, + on_tool: ProgressCallback | None, + ) -> TurnResult: + from cursor_sdk import LocalSendOptions, SendOptions - async def list_models(self) -> list[Any]: - raise NotImplementedError("Model selection isn't supported for Cursor yet.") + usage_tracker = RunUsageTracker() + run = await agent.send( + message, + SendOptions( + # Passed on every send: a resumed agent doesn't remember its + # model, and /model takes effect here. + model=await self._model_selection(), + mcp_servers=mcp_servers, + on_delta=usage_tracker.on_delta, + local=LocalSendOptions(force=True) if force else None, + ), + ) + result = await collect_run_result( + run, max_turns=max_turns, usage_tracker=usage_tracker, on_tool=on_tool, + ) + result.session_id = agent.agent_id + return result - async def effort_levels(self) -> list[str] | None: - return None + async def reset_session(self, session_key: str) -> None: + session = self._sessions.get(session_key) + if session is None: + return + async with session.lock: + await self._close_agent(session) + + # -- Model selection --------------------------------------------------- + + async def list_models(self) -> list[ModelInfo]: + client = await self._get_client() + models = await client.models.list(api_key=self._api_key) + infos: list[ModelInfo] = [] + params: dict[str, str] = {} + for m in models: + param = _effort_param(m) + if param is not None: + params[m.id] = param.id + infos.append(ModelInfo( + id=m.id, + display_name=m.display_name or m.id, + created_at="", + effort_levels=[v.value for v in param.values] if param else [], + )) + self._models, self._effort_params = infos, params + return infos + + async def _model_selection(self) -> Any: + """The model (and effort parameter, if set) to run with.""" + from cursor_sdk import ModelParameterValue, ModelSelection + + if not self._effort: + return ModelSelection(id=self._model) + if self._model not in self._effort_params: + await self._model_info(self._model) # learn the parameter's id + param = self._effort_params.get(self._model) + if param is None: + return ModelSelection(id=self._model) + return ModelSelection( + id=self._model, + params=[ModelParameterValue(id=param, value=self._effort)], + ) async def set_model(self, model_id: str) -> str: - raise NotImplementedError("Model selection isn't supported for Cursor yet.") + """Switch from the next turn on. The model is passed with every send, + so open agents keep their conversation and need no reconnect.""" + info = await self._known_model(model_id.strip()) + effort = self._effort + note = "" + if self._effort and self._effort not in info.effort_levels: + effort = None + note = ( + f"{info.display_name} doesn't support '{self._effort}' effort, " + "so its default is used." + ) + self._apply_model(info.id, effort) + return note async def set_effort(self, level: str) -> None: - raise NotImplementedError("Effort selection isn't supported for Cursor yet.") - - async def _launch_client(self) -> Any: - from cursor_sdk import AsyncClient + level = level.strip() + levels = await self.effort_levels() + if levels is None: + raise ValueError("Couldn't load the model list right now; try again later.") + if not levels: + raise ValueError("The current model doesn't support an effort setting.") + match = next((v for v in levels if v.lower() == level.lower()), None) + if match is None: + raise ValueError( + f"The current model doesn't support '{level}'. " + f"Supported: {', '.join(levels)}." + ) + self._apply_model(self._model, match) - return await AsyncClient.launch_bridge(workspace=self._cwd) + # -- Runtime ----------------------------------------------------------- async def run_one_shot(self, *, system_prompt: str, user_message: str) -> str: - from contextlib import asynccontextmanager - from cursor_sdk import AsyncAgent, CursorAgentError if not self._api_key: @@ -290,27 +526,22 @@ async def run_one_shot(self, *, system_prompt: str, user_message: str) -> str: options = _agent_options( api_key=self._api_key, cwd=self._cwd, - model=self._model, + model=await self._model_selection(), ) - @asynccontextmanager - async def client_ctx(): - client = await self._launch_client() - try: - yield client - finally: - await client.aclose() - + client = await self._get_client() try: - async with client_ctx() as client: - result = await AsyncAgent.prompt(prompt, options, client=client) - text = extract_run_text(result) - if not text.strip(): - return "I couldn't generate a response." - return text + # A throwaway agent on the shared bridge (created, run, closed). + result = await AsyncAgent.prompt(prompt, options, client=client) except CursorAgentError as exc: + if _bridge_failed(exc): + await self._drop_client(client) logger.error("Cursor SDK one-shot failed: {msg}", msg=exc.message) raise RuntimeError(f"Cursor SDK error: {exc.message}") from exc + text = extract_run_text(result) + if not text.strip(): + return "I couldn't generate a response." + return text async def run_turn( self, @@ -326,63 +557,51 @@ async def run_turn( max_turns: int = 10, on_tool: ProgressCallback | None = None, ) -> TurnResult: - from cursor_sdk import CursorAgentError, SendOptions + from cursor_sdk import CursorAgentError if not self._api_key: raise RuntimeError("CURSOR_API_KEY is not configured") await self._ensure_mcp_server(tool_executor) - - message = _build_user_message( - system_prompt=system_prompt, - context=context, - user_message=user_message, - transcript=list(self._transcripts.get(session_key, ())), - image_b64=image_b64, - image_media_type=image_media_type, - ) - mcp_config = self._mcp_server.config if mcp_config is None: raise RuntimeError("MCP server failed to start") - mcp_servers = mcp_servers_for(mcp_config) - usage_tracker = RunUsageTracker() - client = await self._launch_client() - try: - agent = await client.agents.create( - _agent_options( - api_key=self._api_key, - cwd=self._cwd, - model=self._model, - mcp_servers=mcp_servers, - ) - ) + + session = self._sessions.setdefault(session_key, _Session()) + async with session.lock: + client = await self._get_client() try: - run = await agent.send( - message, - SendOptions( - mcp_servers=mcp_servers, - on_delta=usage_tracker.on_delta, - ), + agent = await self._ensure_agent( + session, client, resume=resume_session_id, mcp_servers=mcp_servers, ) - result = await collect_run_result( - run, - max_turns=max_turns, - usage_tracker=usage_tracker, - on_tool=on_tool, + message = _build_user_message( + # A Cursor agent has no system-prompt field; a new one + # gets it at the top of its first message. + system_prompt=system_prompt if agent == "created" else "", + context=context, + user_message=user_message, + image_b64=image_b64, + image_media_type=image_media_type, ) - finally: - await agent.close() - - if not result.text.strip(): - result.text = "I couldn't generate a response." - _apply_cost_fallback(result, bills_per_token=self.bills_per_token) - self._remember(session_key, "user", user_message) - self._remember(session_key, "assistant", result.text) - return result - except CursorAgentError as exc: - logger.error("Cursor SDK turn failed: {msg}", msg=exc.message) - raise RuntimeError(f"Cursor SDK error: {exc.message}") from exc - finally: - await client.aclose() + result = await self._send( + session.agent, message, mcp_servers=mcp_servers, + # A resumed agent may still show a run that died with + # the previous process; take it over. + force=agent == "resumed", + max_turns=max_turns, on_tool=on_tool, + ) + except BaseException as exc: + # The agent may be mid-run or gone; reopen (resuming) next time. + await self._close_agent(session) + if _bridge_failed(exc): + await self._drop_client(client) + if isinstance(exc, CursorAgentError): + logger.error("Cursor SDK turn failed: {msg}", msg=exc.message) + raise RuntimeError(f"Cursor SDK error: {exc.message}") from exc + raise + + if not result.text.strip(): + result.text = "I couldn't generate a response." + _apply_cost_fallback(result, bills_per_token=self.bills_per_token) + return result diff --git a/memclaw/backends/model_selection.py b/memclaw/backends/model_selection.py new file mode 100644 index 0000000..aef1bf9 --- /dev/null +++ b/memclaw/backends/model_selection.py @@ -0,0 +1,84 @@ +"""/model and /effort state shared by the backends that list their models.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from loguru import logger + +if TYPE_CHECKING: + from ..config import MemclawConfig + from .claude_models import ModelInfo + + +class ModelSelectionMixin: + """The current model and effort, lookups in the backend's model list, + and saving a switch to ~/.memclaw/.env. + + A backend sets `_label` (for logs) and `_env_prefix` (CLAUDE → the + CLAUDE_MODEL / CLAUDE_EFFORT keys and the claude_model / claude_effort + config fields), keeps `_model`, `_effort` and `_models`, and implements + `list_models()`, `set_model()` and `set_effort()`. + """ + + _label: ClassVar[str] + _env_prefix: ClassVar[str] + + config: "MemclawConfig" + _model: str + _effort: str | None + _models: list["ModelInfo"] + + async def list_models(self) -> list["ModelInfo"]: + raise NotImplementedError + + @property + def model(self) -> str: + return self._model + + @property + def effort(self) -> str | None: + return self._effort + + async def _model_info(self, model_id: str) -> "ModelInfo | None": + """*model_id*'s entry in the model list, or None if it isn't listed + or the list can't be fetched.""" + if not self._models: + try: + await self.list_models() + except Exception as exc: + logger.warning("Could not fetch the {b} model list: {exc}", b=self._label, exc=exc) + return None + return next((m for m in self._models if m.id == model_id), None) + + async def _known_model(self, model_id: str) -> "ModelInfo": + """*model_id*'s entry, or ValueError. An id that can't be checked + (the list didn't load) is refused too: a typo would break every turn + until ~/.memclaw/.env is fixed by hand.""" + info = await self._model_info(model_id) + if info is not None: + return info + if not self._models: + raise ValueError("Couldn't load the model list; try again later.") + raise ValueError(f"Unknown model '{model_id}'.") + + async def effort_levels(self) -> list[str] | None: + info = await self._model_info(self._model) + return list(info.effort_levels) if info else None + + def _apply_model(self, model: str, effort: str | None) -> None: + """Use *model* / *effort* from the next turn on and save them.""" + from ..setup import update_env_file # local import — setup imports backends + + logger.info( + "{b} model: {m} effort: {e} -> {m2} effort: {e2}", + b=self._label, m=self._model, e=self._effort, m2=model, e2=effort, + ) + self._model, self._effort = model, effort + prefix = self._env_prefix + setattr(self.config, f"{prefix.lower()}_model", model) + setattr(self.config, f"{prefix.lower()}_effort", effort or "") + if effort: + update_env_file({f"{prefix}_MODEL": model, f"{prefix}_EFFORT": effort}) + else: + update_env_file({f"{prefix}_MODEL": model}, drop_keys=[f"{prefix}_EFFORT"]) diff --git a/memclaw/config.py b/memclaw/config.py index 9dc087d..f7da71b 100644 --- a/memclaw/config.py +++ b/memclaw/config.py @@ -46,6 +46,7 @@ class MemclawConfig: # Cursor SDK backend settings cursor_api_key: str = "" cursor_model: str = "" + cursor_effort: str = "" mcp_http_port: int = 17373 # Memory consolidation @@ -80,6 +81,8 @@ def __post_init__(self): self.cursor_api_key = os.environ.get("CURSOR_API_KEY", "") if not self.cursor_model: self.cursor_model = os.environ.get("CURSOR_MODEL", "") + if not self.cursor_effort: + self.cursor_effort = os.environ.get("CURSOR_EFFORT", "") env_mcp_port = os.environ.get("MEMCLAW_MCP_PORT", "").strip() if env_mcp_port: try: diff --git a/pyproject.toml b/pyproject.toml index fbeecc7..e3dfa05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,13 +51,13 @@ Issues = "https://github.com/emanuilo/memclaw/issues" [project.optional-dependencies] cursor = [ - "cursor-sdk>=0.1.0", + "cursor-sdk>=0.1.4,<0.2", ] dev = [ "pytest>=9.0.0", "pytest-asyncio>=1.0.0", "ruff>=0.9.0", - "cursor-sdk>=0.1.0", + "cursor-sdk>=0.1.4,<0.2", ] [tool.hatch.build.targets.wheel] diff --git a/tests/backends/test_cursor.py b/tests/backends/test_cursor.py index 3f50250..5604b9b 100644 --- a/tests/backends/test_cursor.py +++ b/tests/backends/test_cursor.py @@ -2,13 +2,21 @@ from __future__ import annotations +import asyncio import os +from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +import httpx import pytest -from cursor_sdk import HttpMcpServerConfig +from cursor_sdk import ( + HttpMcpServerConfig, + ModelParameterDefinition, + ModelParameterDefinitionValue, + SDKModel, +) from memclaw.backends.base import TurnResult from memclaw.backends import REGISTRY, get_backend_class @@ -35,7 +43,7 @@ @pytest.fixture(autouse=True) def _isolate_credentials(monkeypatch): """Prevent shell env from leaking into MemclawConfig.""" - for name in ("CURSOR_API_KEY", "CURSOR_MODEL"): + for name in ("CURSOR_API_KEY", "CURSOR_MODEL", "CURSOR_EFFORT"): monkeypatch.delenv(name, raising=False) @@ -44,6 +52,7 @@ def _make_config( *, cursor_api_key: str = "", cursor_model: str = "", + cursor_effort: str = "", ) -> MemclawConfig: return MemclawConfig( memory_dir=tmp_path / "m", @@ -51,6 +60,7 @@ def _make_config( anthropic_api_key="test-anthropic-key", cursor_api_key=cursor_api_key, cursor_model=cursor_model, + cursor_effort=cursor_effort, ) @@ -98,11 +108,17 @@ def test_init_reads_config(self, tmp_path): def test_status_rows_show_the_model(self, tmp_path): cfg = _make_config(tmp_path, cursor_model="composer-3") - assert CursorAgentBackend.status_rows(cfg) == [("Model", "composer-3")] + assert CursorAgentBackend.status_rows(cfg) == [ + ("Model", "composer-3"), ("Effort", "default"), + ] + + def test_status_rows_show_the_effort(self, tmp_path): + cfg = _make_config(tmp_path, cursor_effort="high") + assert ("Effort", "high") in CursorAgentBackend.status_rows(cfg) def test_status_rows_fall_back_to_default_model(self, tmp_path): rows = CursorAgentBackend.status_rows(_make_config(tmp_path)) - assert rows == [("Model", "composer-2.5")] + assert rows[0] == ("Model", "composer-2.5") class TestPromptBuilding: @@ -140,6 +156,12 @@ def test_combined_prompt_includes_system_and_user(self): assert "Hello" in prompt assert "---" in prompt + def test_follow_up_message_has_no_system_prompt(self): + message = _build_user_message( + system_prompt="", context="c", user_message="Hi", + ) + assert message == "c\n\nHi" + def test_image_uses_sdk_image(self): message = _build_user_message( system_prompt="Sys", @@ -296,95 +318,292 @@ def test_record_interaction_usage_falls_back_to_token_delta(self): assert result.output_tokens == 42 +_MCP = HttpMcpServerConfig(url="http://127.0.0.1:8765/mcp", type="http") + + +def _make_run(reply: str = "Turn response", num_turns: int = 1): + run = AsyncMock() + run.events = MagicMock(return_value=_empty_events()) + run.wait = AsyncMock(return_value=SimpleNamespace(result=reply, num_turns=num_turns)) + return run + + +def _make_agent(agent_id: str = "agent-1", replies=None): + """A mock AsyncAgent that records each sent message.""" + agent = MagicMock() + agent.agent_id = agent_id + agent.sent = [] + replies = iter(replies or ["Turn response"] * 10) + + async def _send(message, options): + agent.sent.append((message, options)) + return _make_run(next(replies)) + + agent.send = AsyncMock(side_effect=_send) + agent.close = AsyncMock() + return agent + + +def _make_client(*, create=None, resume=None, process=None): + client = MagicMock() + client._owned_bridge = MagicMock(process=process) if process else None + client.agents.create = AsyncMock(side_effect=create or [_make_agent()]) + client.agents.resume = AsyncMock(side_effect=resume or []) + client.models.list = AsyncMock(return_value=[]) + client.aclose = AsyncMock() + return client + + +@contextmanager +def _patched(backend, *clients): + """Run *backend* against mock bridge clients (one per launch) and a stub MCP server.""" + with patch.object( + backend, "_launch_client", new_callable=AsyncMock, side_effect=list(clients), + ) as launch, patch.object( + backend, "_ensure_mcp_server", new_callable=AsyncMock + ), patch.object( + HttpMcpServer, "config", new_callable=PropertyMock, return_value=_MCP + ): + yield launch + + +async def _turn(backend, text="User", *, key="cli", resume=None, context="ctx"): + return await backend.run_turn( + system_prompt="System", context=context, user_message=text, + tool_executor=MagicMock(), session_key=key, resume_session_id=resume, + ) + + +def _backend(tmp_path, **kwargs) -> CursorAgentBackend: + return CursorAgentBackend(_make_config(tmp_path, cursor_api_key="crsr_test_key", **kwargs)) + + +def _sdk_model(model_id, *, effort_values=()): + params = () + if effort_values: + params = (ModelParameterDefinition( + id="reasoning", + values=tuple(ModelParameterDefinitionValue(value=v) for v in effort_values), + ),) + return SDKModel(id=model_id, display_name=model_id.title(), parameters=params) + + class TestCursorAgentBackendRuns: @pytest.mark.asyncio async def test_run_one_shot(self, tmp_path): - cfg = _make_config(tmp_path, cursor_api_key="crsr_test_key") - backend = CursorAgentBackend(cfg) + backend = _backend(tmp_path) + client = _make_client() - mock_result = SimpleNamespace(result="Hello from Cursor") - - with patch("cursor_sdk.AsyncClient") as mock_client_cls, patch( + with _patched(backend, client), patch( "cursor_sdk.AsyncAgent.prompt", new_callable=AsyncMock ) as mock_prompt: - mock_client = AsyncMock() - mock_client_cls.launch_bridge = AsyncMock(return_value=mock_client) - mock_prompt.return_value = mock_result - - text = await backend.run_one_shot( - system_prompt="System", - user_message="User", - ) + mock_prompt.return_value = SimpleNamespace(result="Hello from Cursor") + text = await backend.run_one_shot(system_prompt="System", user_message="User") assert text == "Hello from Cursor" mock_prompt.assert_awaited_once() call_prompt = mock_prompt.await_args.args[0] assert "System" in call_prompt assert "User" in call_prompt + assert mock_prompt.await_args.kwargs["client"] is client @pytest.mark.asyncio async def test_run_one_shot_missing_key_raises(self, tmp_path): - cfg = _make_config(tmp_path) - backend = CursorAgentBackend(cfg) - + backend = CursorAgentBackend(_make_config(tmp_path)) with pytest.raises(RuntimeError, match="CURSOR_API_KEY"): await backend.run_one_shot(system_prompt="S", user_message="U") + @pytest.mark.asyncio + async def test_launch_runs_bridge_in_memory_dir(self, tmp_path): + """The agent store path follows the bridge's cwd, so it must be fixed.""" + backend = _backend(tmp_path) + with patch( + "cursor_sdk.AsyncClient.launch_bridge", new_callable=AsyncMock, + return_value=MagicMock(_owned_bridge=None), + ) as launch: + await backend._launch_client() + argv = launch.await_args.args[0] + assert argv[:2] == ["/bin/sh", "-c"] + assert argv[3] == backend._cwd + assert launch.await_args.kwargs["workspace"] == backend._cwd + @pytest.mark.asyncio async def test_run_turn(self, tmp_path): - cfg = _make_config(tmp_path, cursor_api_key="crsr_test_key") - backend = CursorAgentBackend(cfg) + backend = _backend(tmp_path) + agent = _make_agent(replies=["Turn response"]) + client = _make_client(create=[agent]) - mock_run = AsyncMock() - mock_run.events = MagicMock(return_value=_empty_events()) - mock_run.wait = AsyncMock(return_value=SimpleNamespace(result="Turn response", num_turns=2)) - mock_agent = AsyncMock() - mock_agent.send = AsyncMock(return_value=mock_run) - mock_agent.close = AsyncMock() - mock_client = AsyncMock() - mock_client.agents.create = AsyncMock(return_value=mock_agent) - mock_mcp = HttpMcpServerConfig(url="http://127.0.0.1:8765/mcp", type="http") - - with patch("cursor_sdk.AsyncClient") as mock_client_cls, patch.object( - backend, "_ensure_mcp_server", new_callable=AsyncMock - ), patch.object( - HttpMcpServer, "config", new_callable=PropertyMock, return_value=mock_mcp - ): - mock_client_cls.launch_bridge = AsyncMock(return_value=mock_client) - - result = await backend.run_turn( - system_prompt="System", - context="ctx", - user_message="User", - tool_executor=MagicMock(), - session_key="cli", - max_turns=5, - ) + with _patched(backend, client): + result = await _turn(backend) assert result.text == "Turn response" - assert result.num_turns == 2 + assert result.session_id == "agent-1" assert result.cost_usd is None - send_options = mock_agent.send.await_args.args[1] + create_options = client.agents.create.await_args.args[0] + assert create_options.mcp_servers == {"memclaw": _MCP} + assert create_options.model.id == "composer-2.5" + _, send_options = agent.sent[0] assert send_options.on_delta is not None - mock_client.agents.create.assert_awaited_once() - create_options = mock_client.agents.create.await_args.args[0] - assert create_options.mcp_servers == {"memclaw": mock_mcp} - send_options = mock_agent.send.await_args.args[1] - assert send_options.mcp_servers == {"memclaw": mock_mcp} - mock_agent.send.assert_awaited_once() - mock_agent.close.assert_awaited_once() + assert send_options.mcp_servers == {"memclaw": _MCP} + assert send_options.model.id == "composer-2.5" + assert send_options.local is None + agent.close.assert_not_awaited() # kept open for the next turn @pytest.mark.asyncio - async def test_run_turn_applies_cost_fallback(self, tmp_path): - cfg = _make_config(tmp_path, cursor_api_key="crsr_test_key") - backend = CursorAgentBackend(cfg) + async def test_agent_is_reused_and_system_prompt_only_sent_first(self, tmp_path): + backend = _backend(tmp_path) + agent = _make_agent(replies=["First", "Second"]) + client = _make_client(create=[agent]) + + with _patched(backend, client) as launch: + first = await _turn(backend, "My name is Ana") + second = await _turn(backend, "What's my name?", resume=first.session_id) + + launch.assert_awaited_once() # one bridge for the process + client.agents.create.assert_awaited_once() + client.agents.resume.assert_not_awaited() + assert second.session_id == "agent-1" + first_msg, second_msg = agent.sent[0][0], agent.sent[1][0] + assert first_msg.startswith("System") + assert "ctx" in first_msg and "My name is Ana" in first_msg + assert "System" not in second_msg + assert second_msg == "ctx\n\nWhat's my name?" - async def _events(): - yield SimpleNamespace(sdk_message=None, interaction_update=None, result=None) + @pytest.mark.asyncio + async def test_sessions_are_separate_per_key(self, tmp_path): + backend = _backend(tmp_path) + a, b = _make_agent("agent-a"), _make_agent("agent-b") + client = _make_client(create=[a, b]) - mock_run = AsyncMock() - mock_run.events = MagicMock(return_value=_events()) - mock_run.wait = AsyncMock(return_value=SimpleNamespace(result="Turn response", num_turns=1)) + with _patched(backend, client): + ra = await _turn(backend, key="chat-a") + rb = await _turn(backend, key="chat-b") + + assert (ra.session_id, rb.session_id) == ("agent-a", "agent-b") + + @pytest.mark.asyncio + async def test_resume_passes_model_and_forces_first_send(self, tmp_path): + backend = _backend(tmp_path, cursor_model="gpt-6") + agent = _make_agent("agent-old", replies=["One", "Two"]) + client = _make_client(resume=[agent]) + + with _patched(backend, client): + result = await _turn(backend, resume="agent-old") + await _turn(backend, resume="agent-old") + + client.agents.create.assert_not_awaited() + agent_id, options = client.agents.resume.await_args.args + assert agent_id == "agent-old" + assert options.model.id == "gpt-6" + assert options.mcp_servers == {"memclaw": _MCP} + assert options.local.cwd == backend._cwd + assert result.session_id == "agent-old" + # No system prompt for a resumed agent; it already has one. + assert "System" not in agent.sent[0][0] + assert agent.sent[0][1].local.force is True + assert agent.sent[1][1].local is None + + @pytest.mark.asyncio + async def test_resume_failure_starts_fresh_agent(self, tmp_path): + import cursor_sdk + + # The SDK's "no such agent" error: UnknownAgentError up to 0.1.4, + # AgentNotFoundError later. Both are CursorAgentErrors. + not_found = getattr(cursor_sdk, "AgentNotFoundError", None) or cursor_sdk.UnknownAgentError + backend = _backend(tmp_path) + fresh = _make_agent("agent-new") + client = _make_client( + resume=[not_found("Agent agent-gone not found")], create=[fresh], + ) + + with _patched(backend, client): + result = await _turn(backend, resume="agent-gone") + + assert result.session_id == "agent-new" + assert fresh.sent[0][0].startswith("System") + + @pytest.mark.asyncio + async def test_failed_turn_drops_agent_and_resumes_next_time(self, tmp_path): + from cursor_sdk import CursorSDKError + + backend = _backend(tmp_path) + broken = _make_agent("agent-1") + broken.send = AsyncMock(side_effect=CursorSDKError("boom")) + again = _make_agent("agent-1") + client = _make_client(create=[broken], resume=[again]) + + with _patched(backend, client) as launch: + with pytest.raises(RuntimeError, match="Cursor SDK error: boom"): + await _turn(backend) + broken.close.assert_awaited_once() + result = await _turn(backend, resume="agent-1") + + assert result.session_id == "agent-1" + launch.assert_awaited_once() # the bridge itself was fine + client.aclose.assert_not_awaited() + + @pytest.mark.asyncio + async def test_dead_bridge_is_relaunched(self, tmp_path): + import httpx + from cursor_sdk import NetworkError + + backend = _backend(tmp_path) + dead = _make_agent("agent-1") + err = NetworkError("connection refused") + err.__cause__ = httpx.ConnectError("refused") + dead.send = AsyncMock(side_effect=err) + old_client = _make_client(create=[dead]) + new_client = _make_client(resume=[_make_agent("agent-1")]) + + with _patched(backend, old_client, new_client) as launch: + with pytest.raises(RuntimeError): + await _turn(backend) + await _turn(backend, resume="agent-1") + + assert launch.await_count == 2 + old_client.aclose.assert_awaited_once() + new_client.agents.resume.assert_awaited_once() + + @pytest.mark.asyncio + async def test_reset_session_closes_agent(self, tmp_path): + backend = _backend(tmp_path) + first, second = _make_agent("agent-1"), _make_agent("agent-2") + client = _make_client(create=[first, second]) + + with _patched(backend, client): + await _turn(backend) + await backend.reset_session("cli") + result = await _turn(backend) # agent.py passes no resume id after /new + + first.close.assert_awaited_once() + assert result.session_id == "agent-2" + assert second.sent[0][0].startswith("System") + + @pytest.mark.asyncio + async def test_reset_unknown_session_is_noop(self, tmp_path): + await _backend(tmp_path).reset_session("nope") + + @pytest.mark.asyncio + async def test_shutdown_closes_agents_and_bridge(self, tmp_path): + backend = _backend(tmp_path) + agent = _make_agent() + client = _make_client(create=[agent]) + + with _patched(backend, client), patch.object( + backend._mcp_server, "stop", new_callable=AsyncMock + ) as stop_mcp: + await _turn(backend) + await backend.on_agent_shutdown() + + agent.close.assert_awaited_once() + client.aclose.assert_awaited_once() + stop_mcp.assert_awaited_once() + assert backend._client is None + + @pytest.mark.asyncio + async def test_run_turn_applies_cost_fallback(self, tmp_path): + backend = _backend(tmp_path) + agent = _make_agent() async def _send(message, options): options.on_delta( @@ -398,30 +617,13 @@ async def _send(message, options): }, ) ) - return mock_run - - mock_agent = AsyncMock() - mock_agent.send = AsyncMock(side_effect=_send) - mock_agent.close = AsyncMock() - mock_client = AsyncMock() - mock_client.agents.create = AsyncMock(return_value=mock_agent) - mock_mcp = HttpMcpServerConfig(url="http://127.0.0.1:8765/mcp", type="http") - - with patch("cursor_sdk.AsyncClient") as mock_client_cls, patch.object( - backend, "_ensure_mcp_server", new_callable=AsyncMock - ), patch.object( - HttpMcpServer, "config", new_callable=PropertyMock, return_value=mock_mcp - ): - mock_client_cls.launch_bridge = AsyncMock(return_value=mock_client) - - result = await backend.run_turn( - system_prompt="System", - context="ctx", - user_message="User", - tool_executor=MagicMock(), - session_key="cli", - max_turns=5, - ) + return _make_run() + + agent.send = AsyncMock(side_effect=_send) + client = _make_client(create=[agent]) + + with _patched(backend, client): + result = await _turn(backend) assert result.input_tokens == 1000 assert result.output_tokens == 500 @@ -432,76 +634,13 @@ async def _send(message, options): @pytest.mark.asyncio async def test_run_turn_without_mcp_server_raises(self, tmp_path): - cfg = _make_config(tmp_path, cursor_api_key="crsr_test_key") - backend = CursorAgentBackend(cfg) + backend = _backend(tmp_path) with patch.object(backend, "_ensure_mcp_server", new_callable=AsyncMock), patch.object( HttpMcpServer, "config", new_callable=PropertyMock, return_value=None ): with pytest.raises(RuntimeError, match="MCP server failed to start"): - await backend.run_turn( - system_prompt="System", - context="", - user_message="User", - tool_executor=MagicMock(), - session_key="cli", - ) - - @pytest.mark.asyncio - async def test_interim_transcript_is_folded_into_later_prompts(self, tmp_path): - cfg = _make_config(tmp_path, cursor_api_key="crsr_test_key") - backend = CursorAgentBackend(cfg) - - sent: list[str] = [] - - def _make_run(reply): - run = AsyncMock() - run.events = MagicMock(return_value=_empty_events()) - run.wait = AsyncMock(return_value=SimpleNamespace(result=reply, num_turns=1)) - return run - - replies = iter(["First reply", "Second reply", "Third reply"]) - - async def _send(message, options): - sent.append(message) - return _make_run(next(replies)) - - mock_agent = AsyncMock() - mock_agent.send = AsyncMock(side_effect=_send) - mock_agent.close = AsyncMock() - mock_client = AsyncMock() - mock_client.agents.create = AsyncMock(return_value=mock_agent) - mock_mcp = HttpMcpServerConfig(url="http://127.0.0.1:8765/mcp", type="http") - - async def _turn(key, text): - return await backend.run_turn( - system_prompt="System", context="ctx", - user_message=text, tool_executor=MagicMock(), session_key=key, - ) - - with patch("cursor_sdk.AsyncClient") as mock_client_cls, patch.object( - backend, "_ensure_mcp_server", new_callable=AsyncMock - ), patch.object( - HttpMcpServer, "config", new_callable=PropertyMock, return_value=mock_mcp - ): - mock_client_cls.launch_bridge = AsyncMock(return_value=mock_client) - await _turn("cli", "My name is Ana") - await _turn("cli", "What's my name?") - await backend.reset_session("cli") - await _turn("cli", "Fresh start") - - assert "RECENT CONVERSATION" not in sent[0] - assert "ctx" in sent[0] - assert "User: My name is Ana" in sent[1] - assert "Assistant: First reply" in sent[1] - # The context block itself isn't kept in the transcript. - assert sent[1].count("ctx") == 1 - assert "RECENT CONVERSATION" not in sent[2] - - def test_model_selection_is_not_supported_yet(self, tmp_path): - backend = CursorAgentBackend(_make_config(tmp_path, cursor_api_key="k")) - assert backend.supports_model_selection is False - assert backend.model == "composer-2.5" + await _turn(backend) @pytest.mark.asyncio async def test_agent_start_starts_mcp_server(self, tmp_path, monkeypatch): @@ -527,6 +666,204 @@ async def test_agent_start_starts_mcp_server(self, tmp_path, monkeypatch): mock_stop.assert_awaited_once() +class TestBridgeRobustness: + @pytest.mark.asyncio + async def test_dead_bridge_is_relaunched_before_the_next_call(self, tmp_path): + backend = _backend(tmp_path) + dead = _make_client(process=MagicMock(returncode=1)) + fresh = _make_client() + with patch.object( + backend, "_launch_client", new_callable=AsyncMock, side_effect=[dead, fresh], + ): + assert await backend._get_client() is dead + assert await backend._get_client() is fresh + dead.aclose.assert_awaited_once() + + @pytest.mark.asyncio + async def test_running_bridge_is_reused(self, tmp_path): + backend = _backend(tmp_path) + alive = _make_client(process=MagicMock(returncode=None)) + with patch.object(backend, "_launch_client", new_callable=AsyncMock, side_effect=[alive]): + assert await backend._get_client() is alive + assert await backend._get_client() is alive + + def test_timeout_is_not_a_dead_bridge(self): + from memclaw.backends.cursor import _bridge_failed + + def raised_from(cause): + exc = RuntimeError("run failed") + exc.__cause__ = cause + return exc + + assert _bridge_failed(raised_from(httpx.ConnectError("refused"))) + assert not _bridge_failed(raised_from(httpx.ReadTimeout("slow"))) + assert not _bridge_failed(RuntimeError("agent error")) + + @pytest.mark.asyncio + async def test_drain_survives_a_line_over_64_kib(self): + from memclaw.backends.cursor import _drain_stderr + + stream = asyncio.StreamReader() + stream.feed_data(b"x" * 200_000 + b"\nok\n") + stream.feed_eof() + await _drain_stderr(stream) # reads to EOF without raising + assert stream.at_eof() + + @pytest.mark.asyncio + async def test_shutdown_tolerates_a_session_added_meanwhile(self, tmp_path): + from memclaw.backends.cursor import _Session + + backend = _backend(tmp_path) + backend._mcp_server = MagicMock(stop=AsyncMock()) + + async def close_and_add(session): + backend._sessions.setdefault("late", _Session()) + + backend._sessions["a"] = _Session() + with patch.object(backend, "_close_agent", side_effect=close_and_add): + await backend.on_agent_shutdown() + assert backend._sessions == {} + + +class TestCursorModelSelection: + @pytest.fixture(autouse=True) + def _env_file(self, tmp_path, monkeypatch): + self.env_file = tmp_path / ".env" + monkeypatch.setattr("memclaw.setup.ENV_FILE", self.env_file) + + def _env(self) -> str: + return self.env_file.read_text() if self.env_file.exists() else "" + + def test_supports_model_selection(self, tmp_path): + backend = _backend(tmp_path) + assert backend.supports_model_selection is True + assert backend.model == "composer-2.5" + assert backend.effort is None + + @pytest.mark.asyncio + async def test_list_models_maps_sdk_models(self, tmp_path): + backend = _backend(tmp_path) + client = _make_client() + client.models.list.return_value = [ + _sdk_model("composer-2.5"), + _sdk_model("gpt-6", effort_values=("low", "medium", "high")), + ] + + with _patched(backend, client): + models = await backend.list_models() + + client.models.list.assert_awaited_once_with(api_key="crsr_test_key") + assert [(m.id, m.display_name, m.effort_levels) for m in models] == [ + ("composer-2.5", "Composer-2.5", []), + ("gpt-6", "Gpt-6", ["low", "medium", "high"]), + ] + + @pytest.mark.asyncio + async def test_set_model_persists_and_applies_on_next_send(self, tmp_path): + backend = _backend(tmp_path) + agent = _make_agent(replies=["One", "Two"]) + client = _make_client(create=[agent]) + client.models.list.return_value = [_sdk_model("composer-2.5"), _sdk_model("gpt-6")] + + with _patched(backend, client): + await _turn(backend) + assert await backend.set_model("gpt-6") == "" + await _turn(backend) + + assert backend.model == "gpt-6" + assert "CURSOR_MODEL=gpt-6" in self._env() + assert agent.sent[0][1].model.id == "composer-2.5" + assert agent.sent[1][1].model.id == "gpt-6" + client.agents.create.assert_awaited_once() # same agent, no reconnect + + @pytest.mark.asyncio + async def test_set_unknown_model_raises(self, tmp_path): + backend = _backend(tmp_path) + client = _make_client() + client.models.list.return_value = [_sdk_model("composer-2.5")] + + with _patched(backend, client), pytest.raises(ValueError, match="Unknown model"): + await backend.set_model("nope") + + @pytest.mark.asyncio + async def test_switch_is_refused_when_the_list_is_unavailable(self, tmp_path): + backend = _backend(tmp_path) + client = _make_client() + client.models.list.side_effect = RuntimeError("offline") + + with _patched(backend, client), pytest.raises(ValueError, match="Couldn't load"): + await backend.set_model("composr-2.5") + assert backend.model == "composer-2.5" + assert "CURSOR_MODEL" not in self._env() + + @pytest.mark.asyncio + async def test_effort_is_sent_as_model_parameter(self, tmp_path): + backend = _backend(tmp_path) + agent = _make_agent() + client = _make_client(create=[agent]) + client.models.list.return_value = [ + _sdk_model("gpt-6", effort_values=("low", "high")), + ] + + with _patched(backend, client): + await backend.set_model("gpt-6") + assert await backend.effort_levels() == ["low", "high"] + await backend.set_effort("HIGH") + await _turn(backend) + + assert backend.effort == "high" + assert "CURSOR_EFFORT=high" in self._env() + selection = agent.sent[0][1].model + assert selection.id == "gpt-6" + assert [(p.id, p.value) for p in selection.params] == [("reasoning", "high")] + + @pytest.mark.asyncio + async def test_effort_rejected_when_model_has_none(self, tmp_path): + backend = _backend(tmp_path) + client = _make_client() + client.models.list.return_value = [_sdk_model("composer-2.5")] + + with _patched(backend, client): + assert await backend.effort_levels() == [] + with pytest.raises(ValueError, match="doesn't support an effort"): + await backend.set_effort("high") + + @pytest.mark.asyncio + async def test_switching_to_model_without_effort_clears_it(self, tmp_path): + backend = _backend(tmp_path, cursor_effort="high") + client = _make_client() + client.models.list.return_value = [ + _sdk_model("gpt-6", effort_values=("low", "high")), + _sdk_model("composer-2.5"), + ] + + with _patched(backend, client): + note = await backend.set_model("composer-2.5") + + assert "doesn't support 'high'" in note + assert backend.effort is None + assert "CURSOR_EFFORT" not in self._env() + + @pytest.mark.asyncio + async def test_model_and_effort_menus_list_cursor_models(self, tmp_path): + from memclaw.bot.handlers import effort_menu, model_menu, select_model + + backend = _backend(tmp_path) + client = _make_client() + client.models.list.return_value = [_sdk_model("composer-2.5"), _sdk_model("gpt-6")] + + with _patched(backend, client): + _text, keyboard = await model_menu(backend) + switched = await select_model(backend, "gpt-6") + effort, effort_keyboard = await effort_menu(backend) + + buttons = [(b.text, b.callback_data) for row in keyboard.inline_keyboard for b in row] + assert buttons == [("✓ Composer-2.5", "m:composer-2.5"), ("Gpt-6", "m:gpt-6")] + assert switched.startswith("Switched to gpt-6.") + assert "doesn't support an effort setting" in effort + assert effort_keyboard is None + + async def _empty_events(): if False: # pragma: no cover - async generator helper yield diff --git a/tests/test_cli_status.py b/tests/test_cli_status.py index ad54208..5936ab4 100644 --- a/tests/test_cli_status.py +++ b/tests/test_cli_status.py @@ -37,13 +37,15 @@ def test_claude_backend_shows_its_model_and_effort(tmp_path: Path, monkeypatch): def test_cursor_backend_shows_only_its_own_model(tmp_path: Path, monkeypatch): monkeypatch.setenv("AGENT_BACKEND", "cursor") monkeypatch.setenv("CURSOR_MODEL", "composer-3") + monkeypatch.delenv("CURSOR_EFFORT", raising=False) monkeypatch.setenv("CLAUDE_MODEL", "claude-opus-5") + monkeypatch.setenv("CLAUDE_EFFORT", "high") output = _status(tmp_path) assert "Model : composer-3" in output + assert "Effort : default" in output assert "claude-opus-5" not in output - assert "Effort" not in output def test_general_rows_are_still_shown(tmp_path: Path): diff --git a/uv.lock b/uv.lock index c999ef0..cbf9c90 100644 --- a/uv.lock +++ b/uv.lock @@ -1007,8 +1007,8 @@ requires-dist = [ { name = "claude-agent-sdk", specifier = ">=0.2.163,<0.3" }, { name = "click", specifier = ">=8.0.0" }, { name = "curl-cffi", specifier = ">=0.7.0" }, - { name = "cursor-sdk", marker = "extra == 'cursor'", specifier = ">=0.1.0" }, - { name = "cursor-sdk", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "cursor-sdk", marker = "extra == 'cursor'", specifier = ">=0.1.4,<0.2" }, + { name = "cursor-sdk", marker = "extra == 'dev'", specifier = ">=0.1.4,<0.2" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "loguru", specifier = ">=0.7.0" }, { name = "mcp", specifier = ">=1.23.0,<2" },