diff --git a/condor/acp/__init__.py b/condor/acp/__init__.py index 2903095a..1d71ff99 100644 --- a/condor/acp/__init__.py +++ b/condor/acp/__init__.py @@ -11,3 +11,13 @@ ACPEvent, ) from .pydantic_ai_client import PydanticAIClient, is_pydantic_ai_model +from .cursor_agent_client import ( + CursorAgentClient, + is_cursor_agent_key, + resolve_cursor_model, +) +from .managed_agent_client import ( + ManagedAgentClient, + is_managed_agent_key, + resolve_managed_model, +) diff --git a/condor/acp/cursor_agent_client.py b/condor/acp/cursor_agent_client.py new file mode 100644 index 00000000..4e07f283 --- /dev/null +++ b/condor/acp/cursor_agent_client.py @@ -0,0 +1,567 @@ +"""Cursor SDK client -- persistent local agents with custom trading tools. + +Drop-in alternative to ManagedAgentClient (start -> prompt_stream -> stop, +yielding the same ACPEvent types) that runs the agent loop via the Cursor SDK +local runtime instead of Anthropic's Managed Agents harness. + +Key properties: + - One persistent local Cursor agent per Condor trading session: each tick is + a follow-up message in the same conversation. + - Trading tools are exposed as SDK custom_tools wrapping McpToolBridge, gated + by the same permission_callback as other providers. + - State (agent id, fingerprint) persists in ``{agent_dir}/state/cursor_agent.json``. + +Requires ``cursor-sdk`` and ``CURSOR_API_KEY``. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +from pathlib import Path +from typing import Any, AsyncIterator + +from .client import ( + ACPEvent, + Heartbeat, + PermissionCallback, + PromptDone, + TextChunk, + ThoughtChunk, + ToolCallEvent, + ToolCallUpdate, +) +from .managed_tools import McpToolBridge + +log = logging.getLogger(__name__) + +CURSOR_AGENT_PREFIX = "cursor-managed" +DEFAULT_CURSOR_MODEL = "composer-2.5" + +_SNAPSHOT_OUTPUT_CHARS = 4000 + +# Shared bridge across per-tick client instances (one workspace). +_bridge_lock = asyncio.Lock() +_bridge_client: Any = None +_bridge_workspace: str = "" + +# Substrings that indicate a wedged Cursor SDK bridge or remote agent. +_RECOVERABLE_ERROR_MARKERS = ( + "bridge request failed", + "internal: internal error", + "internalservererror", + "connecterror", + "remoteprotocolerror", +) + + +async def reset_cursor_bridge() -> None: + """Drop the process-global Cursor SDK bridge so the next tick relaunches it.""" + global _bridge_client, _bridge_workspace + async with _bridge_lock: + _bridge_client = None + _bridge_workspace = "" + + +def is_recoverable_cursor_error(exc: BaseException) -> bool: + """True when resetting the bridge and rotating the remote agent may help.""" + try: + from cursor_sdk import errors as cursor_errors + + if isinstance( + exc, + ( + cursor_errors.InternalServerError, + getattr(cursor_errors, "ConnectError", type(None)), + ), + ): + return True + except ImportError: + pass + + try: + import httpx + + if isinstance(exc, (httpx.ConnectError, httpx.RemoteProtocolError)): + return True + except ImportError: + pass + + lowered = str(exc).lower() + return any(marker in lowered for marker in _RECOVERABLE_ERROR_MARKERS) + + +def is_cursor_provider_error(exc_or_text: Any) -> bool: + """Detect Cursor SDK / bridge failures from an exception or journal text.""" + if isinstance(exc_or_text, BaseException): + return is_recoverable_cursor_error(exc_or_text) + lowered = str(exc_or_text).lower() + return any(marker in lowered for marker in _RECOVERABLE_ERROR_MARKERS) + + +def _blocked_result_text(permission_result: dict[str, Any] | None = None) -> str: + from condor.trading_agent.risk import format_block_result + + reason = None + if permission_result: + reason = permission_result.get("block_reason") + return format_block_result(reason) + + +def is_cursor_agent_key(agent_key: str) -> bool: + """Check if an agent_key routes to the Cursor SDK provider.""" + if not agent_key: + return False + return agent_key == CURSOR_AGENT_PREFIX or agent_key.startswith( + CURSOR_AGENT_PREFIX + ":" + ) + + +def resolve_cursor_model(agent_key: str, config_model: str) -> str: + """Resolve the model id: inline key > config.model > default.""" + if ":" in agent_key: + inline = agent_key.split(":", 1)[1].strip() + if inline: + return inline + return config_model or DEFAULT_CURSOR_MODEL + + +def is_persistent_provider_key(agent_key: str) -> bool: + """Managed or Cursor agents use lean per-tick prompts + system prompt.""" + from .managed_agent_client import is_managed_agent_key + + return is_cursor_agent_key(agent_key) or is_managed_agent_key(agent_key) + + +class CursorAgentClient: + """Runs the trading brain on the Cursor SDK local agent runtime.""" + + def __init__( + self, + model: str, + system_prompt: str, + agent_name: str, + slug: str, + agent_dir: Path | str | None = None, + mcp_servers: list[dict[str, Any]] | None = None, + permission_callback: PermissionCallback | None = None, + persist_session: bool = True, + memory_bootstrap: str = "", + working_dir: str | None = None, + bridge: Any = None, + sdk_client: Any = None, + async_agent: Any = None, + ): + self.model = model + self.system_prompt = system_prompt + self.agent_name = agent_name + self.slug = slug + self.agent_dir = Path(agent_dir) if agent_dir else None + self.permission_callback = permission_callback + self.persist_session = persist_session + self.memory_bootstrap = memory_bootstrap.strip() + self._bootstrap_pending = bool(self.memory_bootstrap) + self.working_dir = working_dir or os.getcwd() + self._bridge = bridge or McpToolBridge(mcp_servers, working_dir=self.working_dir) + self._injected_sdk = sdk_client + self._sdk_client = sdk_client + self._agent = async_agent + self._agent_id = "" + self._started = False + self._custom_tools: dict[str, Any] = {} + + # ------------------------------------------------------------------ + # State file + # ------------------------------------------------------------------ + + def _state_path(self) -> Path | None: + if not self.agent_dir: + return None + return self.agent_dir / "state" / "cursor_agent.json" + + def _load_state(self) -> dict[str, Any]: + path = self._state_path() + if not path or not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + log.warning("Cursor agent state file unreadable: %s", path) + return {} + + def _save_state(self, state: dict[str, Any]) -> None: + path = self._state_path() + if not path: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2), encoding="utf-8") + + def _fingerprint(self, tool_names: list[str]) -> str: + payload = json.dumps( + { + "model": self.model, + "system": self.system_prompt, + "tools": sorted(tool_names), + }, + sort_keys=True, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + # ------------------------------------------------------------------ + # Bridge singleton + # ------------------------------------------------------------------ + + async def _ensure_sdk_client(self, force_relaunch: bool = False) -> Any: + global _bridge_client, _bridge_workspace + if self._injected_sdk is not None: + self._sdk_client = self._injected_sdk + return self._sdk_client + if self._sdk_client is not None and not force_relaunch: + return self._sdk_client + async with _bridge_lock: + if force_relaunch: + _bridge_client = None + _bridge_workspace = "" + self._sdk_client = None + if _bridge_client is None or _bridge_workspace != self.working_dir: + from cursor_sdk import AsyncClient + + _bridge_client = await AsyncClient.launch_bridge( + workspace=self.working_dir + ) + _bridge_workspace = self.working_dir + self._sdk_client = _bridge_client + return self._sdk_client + + async def _recover_cursor_provider(self) -> None: + """Reset bridge + drop persisted agent, then provision a fresh conversation.""" + await reset_cursor_bridge() + self._sdk_client = None + self._agent = None + self._agent_id = "" + if self.agent_dir: + await self.rotate_persisted_agent(self.agent_dir) + await self._ensure_sdk_client(force_relaunch=True) + await self._provision() + + async def _send_with_recovery(self, text: str) -> Any: + """Send a user message, recovering once from bridge/agent failures.""" + assert self._agent is not None, "Cursor agent not provisioned" + try: + return await self._agent.send(text) + except Exception as e: + if not is_recoverable_cursor_error(e): + raise + log.warning( + "Cursor send failed for %s (%s); resetting bridge and agent", + self.slug, + e, + ) + await self._recover_cursor_provider() + assert self._agent is not None + return await self._agent.send(text) + + def _api_key(self) -> str: + key = os.environ.get("CURSOR_API_KEY", "").strip() + if not key: + raise RuntimeError( + "CURSOR_API_KEY is required for cursor-managed agents" + ) + return key + + # ------------------------------------------------------------------ + # Custom tools + # ------------------------------------------------------------------ + + def _build_custom_tools(self) -> dict[str, Any]: + from cursor_sdk import CustomTool + + client = self + tools: dict[str, Any] = {} + + for tool_def in self._bridge.custom_tool_defs: + name = tool_def["name"] + schema = tool_def.get("input_schema") or {"type": "object", "properties": {}} + description = tool_def.get("description") or "" + + async def _execute( + args: dict[str, Any], + context: Any, + *, + _name: str = name, + ) -> str: + tool_input = dict(args or {}) + tool_use_id = getattr(context, "tool_call_id", None) or _name + + if client.permission_callback: + tool_call_info = { + "tool": _name, + "title": _name, + "input": tool_input, + } + options = [ + {"optionId": "allow", "kind": "allow_once"}, + {"optionId": "deny", "kind": "deny"}, + ] + result = await client.permission_callback(tool_call_info, options) + outcome = result.get("outcome", {}) + if isinstance(outcome, dict) and outcome.get("outcome") == "cancelled": + return _blocked_result_text(result) + + output, is_error = await client._bridge.call(_name, tool_input) + if is_error: + return output + return output + + tools[name] = CustomTool( + description=description, + input_schema=schema, + execute=_execute, + ) + return tools + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start MCP bridge and ensure Cursor agent exists.""" + await self._bridge.start() + try: + await self._provision() + except Exception: + await self.stop() + raise + self._started = True + + async def _provision(self) -> None: + from cursor_sdk import AgentOptions, LocalAgentOptions + + client = await self._ensure_sdk_client() + tool_names = [t["name"] for t in self._bridge.custom_tool_defs] + fingerprint = self._fingerprint(tool_names) + self._custom_tools = self._build_custom_tools() + + state = self._load_state() + api_key = self._api_key() + local_opts = LocalAgentOptions( + cwd=self.working_dir, + setting_sources=[], + custom_tools=self._custom_tools, + ) + + reuse_id = "" + if ( + self.persist_session + and state.get("agent_id") + and state.get("agent_fingerprint") == fingerprint + ): + reuse_id = state["agent_id"] + + if reuse_id: + self._agent = await client.agents.resume( + reuse_id, + AgentOptions( + api_key=api_key, + model=self.model, + local=local_opts, + ), + ) + self._agent_id = reuse_id + log.info("Resumed Cursor agent %s for %s", self._agent_id, self.slug) + else: + if state.get("agent_id") and state.get("agent_fingerprint") != fingerprint: + log.info( + "Cursor agent fingerprint changed for %s; provisioning new agent", + self.slug, + ) + self._agent = await client.agents.create( + AgentOptions( + api_key=api_key, + model=self.model, + name=self.agent_name, + local=local_opts, + ), + ) + self._agent_id = self._agent.agent_id + await self._bootstrap_system_prompt() + state["system_bootstrapped"] = True + + state["agent_id"] = self._agent_id if self.persist_session else "" + state["agent_fingerprint"] = fingerprint if self.persist_session else "" + self._save_state(state) + log.info( + "CursorAgentClient ready: model=%s agent=%s tools=%d", + self.model, + self._agent_id, + len(self._custom_tools), + ) + + async def _bootstrap_system_prompt(self) -> None: + """Seed the persistent conversation with static system instructions.""" + assert self._agent is not None + bootstrap = ( + "[SYSTEM INSTRUCTIONS — apply on every tick; do not repeat them]\n" + f"{self.system_prompt}\n\n" + "Acknowledge with exactly one word: READY" + ) + run = await self._send_with_recovery(bootstrap) + await run.wait() + + @staticmethod + async def rotate_persisted_agent(agent_dir: Path | str) -> str: + """Drop persisted agent id so the next start() creates a fresh conversation.""" + path = Path(agent_dir) / "state" / "cursor_agent.json" + if not path.exists(): + return "" + try: + state = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return "" + old_id = state.pop("agent_id", "") or "" + state.pop("system_bootstrapped", None) + if old_id: + path.write_text(json.dumps(state, indent=2), encoding="utf-8") + log.info("Rotated Cursor agent %s (dropped from state)", old_id) + return old_id + + async def stop(self) -> None: + """Close local MCP bridge; keep Cursor agent conversation alive.""" + await self._bridge.stop() + if not self.persist_session and self._agent_id and self._sdk_client is not None: + try: + await self._agent.delete() + log.info("Deleted ephemeral Cursor agent %s", self._agent_id) + except Exception: + log.warning("Failed to delete ephemeral Cursor agent %s", self._agent_id) + self._agent_id = "" + self._agent = None + self._started = False + + @property + def alive(self) -> bool: + return self._started + + # ------------------------------------------------------------------ + # Prompt + # ------------------------------------------------------------------ + + async def prompt(self, text: str) -> str: + chunks: list[str] = [] + async for event in self.prompt_stream(text): + if isinstance(event, TextChunk): + chunks.append(event.text) + return "".join(chunks) + + async def prompt_stream(self, text: str) -> AsyncIterator[ACPEvent]: + assert self._started and self._agent is not None, "Client not started" + + if self._bootstrap_pending and self.memory_bootstrap: + text = ( + "[LOCAL LEARNINGS BOOTSTRAP — from Condor learnings.md; " + "also curate memory/ under your agent directory]\n" + f"{self.memory_bootstrap}\n\n{text}" + ) + self._bootstrap_pending = False + + try: + run = await self._send_with_recovery(text) + except Exception as e: + log.warning("Cursor agent send error: %s", e) + yield TextChunk(text=f"(cursor session error: {e})") + yield PromptDone(stop_reason="error") + return + + loop = asyncio.get_event_loop() + start_time = loop.time() + seen_tools: dict[str, str] = {} + + try: + async for message in run.messages(): + elapsed = loop.time() - start_time + msg_type = getattr(message, "type", "") + + if msg_type == "assistant": + for block in getattr(message.message, "content", []) or []: + if getattr(block, "type", "") == "text": + chunk = getattr(block, "text", "") + if chunk: + yield TextChunk(text=chunk) + + elif msg_type == "thinking": + thought = getattr(message, "text", "") or "(thinking)" + yield ThoughtChunk(text=thought) + + elif msg_type == "tool_call": + call_id = getattr(message, "call_id", "") or getattr( + message, "callId", "" + ) + name = getattr(message, "name", "tool") + status = getattr(message, "status", "in_progress") + args = getattr(message, "args", None) + result = getattr(message, "result", None) + + if status == "running": + seen_tools[call_id] = name + yield ToolCallEvent( + tool_call_id=call_id, + title=name, + status="in_progress", + kind="mcp", + input=args if isinstance(args, dict) else None, + ) + else: + is_error = status == "error" + output = _format_tool_result(result) + yield ToolCallUpdate( + tool_call_id=call_id, + status="failed" if is_error else "completed", + output=output[:_SNAPSHOT_OUTPUT_CHARS] if output else None, + ) + + elif msg_type == "status": + status = getattr(message, "status", "") + if status in ("error", "failed"): + yield TextChunk(text=f"(cursor run status: {status})") + + if elapsed > 285: + yield Heartbeat(elapsed_seconds=elapsed) + + result = await run.wait() + stop_reason = "error" if result.status == "error" else "end_turn" + if result.status == "cancelled": + stop_reason = "cancelled" + if result.status == "error" and result.result: + yield TextChunk(text=f"(cursor run error: {result.result[:500]})") + yield PromptDone(stop_reason=stop_reason) + + except asyncio.CancelledError: + try: + await run.cancel() + except Exception: + pass + raise + except Exception as e: + log.warning("Cursor agent stream error: %s", e) + yield TextChunk(text=f"(cursor session error: {e})") + yield PromptDone(stop_reason="error") + + +def _format_tool_result(result: Any) -> str: + if result is None: + return "" + if isinstance(result, str): + return result + if isinstance(result, dict): + content = result.get("content") + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("text"): + parts.append(str(block["text"])) + return "\n".join(parts) + return json.dumps(result, default=str) + return str(result) diff --git a/condor/acp/managed_agent_client.py b/condor/acp/managed_agent_client.py new file mode 100644 index 00000000..48f1defe --- /dev/null +++ b/condor/acp/managed_agent_client.py @@ -0,0 +1,612 @@ +"""Claude Managed Agents client -- persistent hosted sessions with memory. + +Drop-in alternative to ACPClient / PydanticAIClient (start -> prompt_stream +-> stop, yielding the same ACPEvent types) that runs the agent loop on +Anthropic's Managed Agents harness instead of a local subprocess. + +Key properties: + - One persistent hosted session per Condor trading session: each tick is a + user.message in the same conversation, so the brain natively remembers + earlier ticks (harness handles compaction + prompt caching). + - A workspace memory store is mounted at /mnt/memory inside the sandbox and + survives across sessions -- the agent's self-curated long-term memory. + - Trading tools are *custom tools*: the hosted agent emits structured + requests, Condor executes them locally through the MCP bridge and the + existing permission/risk callback, and posts results back. Credentials + and execution never leave the machine. + +State (agent id, session id, store id) is persisted to +``{agent_dir}/state/managed_agent.json`` so continuity survives restarts. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import inspect +import json +import logging +import time +from pathlib import Path +from typing import Any, AsyncIterator + +from .client import ( + ACPEvent, + Heartbeat, + PermissionCallback, + PromptDone, + TextChunk, + ThoughtChunk, + ToolCallEvent, + ToolCallUpdate, +) +from .managed_tools import McpToolBridge + +log = logging.getLogger(__name__) + +MANAGED_AGENT_PREFIX = "claude-managed" +DEFAULT_MANAGED_MODEL = "claude-sonnet-4-6" +ENVIRONMENT_NAME = "condor-trading" + +# Built-in sandbox tools we keep off: market data comes exclusively from our +# own tools, which keeps inputs deterministic and prompt-injection surface low. +_DISABLED_BUILTIN_TOOLS = ("web_search", "web_fetch") + +def _blocked_result_text(permission_result: dict[str, Any] | None = None) -> str: + from condor.trading_agent.risk import format_block_result + + reason = None + if permission_result: + reason = permission_result.get("block_reason") + return format_block_result(reason) + +# Cap on tool-call output stored in tick snapshots (full text goes to the model). +_SNAPSHOT_OUTPUT_CHARS = 4000 + + +def is_managed_agent_key(agent_key: str) -> bool: + """Check if an agent_key routes to the Managed Agents provider.""" + if not agent_key: + return False + return agent_key == MANAGED_AGENT_PREFIX or agent_key.startswith( + MANAGED_AGENT_PREFIX + ":" + ) + + +def resolve_managed_model(agent_key: str, config_model: str) -> str: + """Resolve the model id: inline key > config.model > default.""" + if ":" in agent_key: + inline = agent_key.split(":", 1)[1].strip() + if inline: + return inline + return config_model or DEFAULT_MANAGED_MODEL + + +class ManagedAgentClient: + """Runs the trading brain on the Claude Managed Agents harness.""" + + def __init__( + self, + model: str, + system_prompt: str, + agent_name: str, + slug: str, + agent_dir: Path | str | None = None, + mcp_servers: list[dict[str, Any]] | None = None, + permission_callback: PermissionCallback | None = None, + persist_session: bool = True, + memory_instructions: str = "", + memory_bootstrap: str = "", + working_dir: str | None = None, + sdk_client: Any = None, + bridge: Any = None, + ): + self.model = model + self.system_prompt = system_prompt + self.agent_name = agent_name + self.slug = slug + self.agent_dir = Path(agent_dir) if agent_dir else None + self.permission_callback = permission_callback + self.persist_session = persist_session + self.memory_instructions = memory_instructions or ( + "Your self-curated trading memory. Review it before trading; update " + "it after closed positions and at session end. Keep entries concise " + "and factual." + ) + self.memory_bootstrap = memory_bootstrap.strip() + self._bootstrap_pending = False + self._sdk = sdk_client + self._bridge = bridge or McpToolBridge(mcp_servers, working_dir=working_dir) + self._agent_id: str = "" + self._session_id: str = "" + self._started = False + + # ------------------------------------------------------------------ + # State file + # ------------------------------------------------------------------ + + def _state_path(self) -> Path | None: + if not self.agent_dir: + return None + return self.agent_dir / "state" / "managed_agent.json" + + def _load_state(self) -> dict[str, Any]: + path = self._state_path() + if not path or not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + log.warning("Managed agent state file unreadable: %s", path) + return {} + + def _save_state(self, state: dict[str, Any]) -> None: + path = self._state_path() + if not path: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2), encoding="utf-8") + + def _fingerprint(self, tool_defs: list[dict[str, Any]]) -> str: + payload = json.dumps( + { + "model": self.model, + "system": self.system_prompt, + "tools": sorted(t["name"] for t in tool_defs), + }, + sort_keys=True, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start the MCP bridge and ensure agent + memory store + session.""" + if self._sdk is None: + import anthropic + + self._sdk = anthropic.AsyncAnthropic() + + await self._bridge.start() + try: + await self._provision() + except Exception: + # Close the bridge in THIS task -- a GC-driven close later would + # trip anyio's cancel-scope task check and leak MCP subprocesses. + await self.stop() + raise + self._started = True + + async def _provision(self) -> None: + """Ensure agent + environment + memory store + session exist.""" + tool_defs = self._bridge.custom_tool_defs + + state = self._load_state() + fingerprint = self._fingerprint(tool_defs) + + # Agent (versioned, recreated when model/system/tools change) + if state.get("agent_id") and state.get("agent_fingerprint") == fingerprint: + self._agent_id = state["agent_id"] + else: + superseded = state.get("agent_id", "") + self._agent_id = await self._create_agent(tool_defs) + state["agent_id"] = self._agent_id + state["agent_fingerprint"] = fingerprint + state.pop("session_id", None) # old session belongs to the old agent + if superseded: + try: + await self._sdk.beta.agents.archive(superseded) + log.info("Archived superseded managed agent %s", superseded) + except Exception: + log.warning("Failed to archive superseded agent %s", superseded) + + environment_id = await self._ensure_environment() + store_id = state.get("memory_store_id") or await self._ensure_memory_store() + state["memory_store_id"] = store_id + + # Session: reuse the persisted one when it is still usable + self._session_id = "" + if self.persist_session and state.get("session_id"): + self._session_id = await self._check_session(state["session_id"]) + + if not self._session_id: + session = await self._sdk.beta.sessions.create( + agent=self._agent_id, + environment_id=environment_id, + title=f"condor:{self.slug}" + ("" if self.persist_session else " (experiment)"), + resources=[ + { + "type": "memory_store", + "memory_store_id": store_id, + "access": "read_write", + "instructions": self.memory_instructions, + } + ], + ) + self._session_id = session.id + if self.memory_bootstrap: + self._bootstrap_pending = True + + state["session_id"] = self._session_id if self.persist_session else "" + self._save_state(state) + log.info( + "ManagedAgentClient ready: model=%s agent=%s session=%s tools=%d", + self.model, self._agent_id, self._session_id, len(tool_defs), + ) + + @staticmethod + async def rotate_persisted_session( + agent_dir: Path | str, sdk_client: Any = None + ) -> str: + """Drop the persisted session so the next start() provisions a fresh one. + + Recovery path for wedged hosted sessions (e.g. a session that stops + echoing user messages). The agent and memory store are kept -- only + the conversation is abandoned. Best-effort deletes the wedged session + server-side. Returns the dropped session id ("" if none). + """ + path = Path(agent_dir) / "state" / "managed_agent.json" + if not path.exists(): + return "" + try: + state = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return "" + session_id = state.pop("session_id", "") or "" + if not session_id: + return "" + path.write_text(json.dumps(state, indent=2), encoding="utf-8") + + if sdk_client is None: + try: + import anthropic + + sdk_client = anthropic.AsyncAnthropic() + except Exception: + return session_id + try: + await sdk_client.beta.sessions.delete(session_id) + log.info("Deleted rotated managed session %s", session_id) + except Exception: + log.warning( + "Could not delete rotated session %s (left idle server-side)", + session_id, + ) + return session_id + + async def stop(self) -> None: + """Close local resources. + + Persistent sessions stay alive on purpose (they carry the loop's + conversation). Ephemeral experiment sessions are deleted so dry runs + don't accumulate server-side conversation data. + """ + await self._bridge.stop() + if not self.persist_session and self._session_id and self._sdk is not None: + try: + await self._sdk.beta.sessions.delete(self._session_id) + log.info("Deleted ephemeral managed session %s", self._session_id) + except Exception: + log.warning( + "Failed to delete ephemeral session %s (idle sessions don't bill)", + self._session_id, + ) + self._session_id = "" + self._started = False + + @property + def alive(self) -> bool: + return self._started + + # ------------------------------------------------------------------ + # Provisioning helpers + # ------------------------------------------------------------------ + + async def _create_agent(self, tool_defs: list[dict[str, Any]]) -> str: + toolset: dict[str, Any] = { + "type": "agent_toolset_20260401", + "configs": [ + {"name": name, "enabled": False} for name in _DISABLED_BUILTIN_TOOLS + ], + } + agent = await self._sdk.beta.agents.create( + name=self.agent_name, + model=self.model, + system=self.system_prompt, + description=f"Condor trading agent '{self.slug}' (managed provider)", + tools=[toolset, *tool_defs], + metadata={"condor_slug": self.slug}, + ) + log.info("Created managed agent %s for %s", agent.id, self.slug) + return agent.id + + async def _ensure_environment(self) -> str: + async for env in self._aiter(self._sdk.beta.environments.list()): + if getattr(env, "name", "") == ENVIRONMENT_NAME and not getattr( + env, "archived_at", None + ): + return env.id + env = await self._sdk.beta.environments.create(name=ENVIRONMENT_NAME) + log.info("Created managed environment %s", env.id) + return env.id + + async def _ensure_memory_store(self) -> str: + store_name = f"condor-{self.slug}-memory" + async for store in self._aiter(self._sdk.beta.memory_stores.list()): + if getattr(store, "name", "") == store_name and not getattr( + store, "archived_at", None + ): + return store.id + store = await self._sdk.beta.memory_stores.create( + name=store_name, + description=f"Long-term trading memory for Condor agent '{self.slug}'.", + ) + log.info("Created memory store %s for %s", store.id, self.slug) + return store.id + + async def _check_session(self, session_id: str) -> str: + """Return session_id if still usable, else empty string.""" + try: + session = await self._sdk.beta.sessions.retrieve(session_id) + except Exception: + log.info("Persisted session %s not retrievable; creating new", session_id) + return "" + status = getattr(session, "status", "") + if status == "terminated" or getattr(session, "archived_at", None): + log.info("Persisted session %s is %s; creating new", session_id, status) + return "" + return session_id + + @staticmethod + async def _aiter(maybe_stream: Any) -> AsyncIterator[Any]: + """Iterate sync or async iterables/awaitables uniformly.""" + if inspect.isawaitable(maybe_stream): + maybe_stream = await maybe_stream + if hasattr(maybe_stream, "__aiter__"): + async for item in maybe_stream: + yield item + else: + for item in maybe_stream: + yield item + + # ------------------------------------------------------------------ + # Prompt + # ------------------------------------------------------------------ + + async def prompt(self, text: str) -> str: + chunks: list[str] = [] + async for event in self.prompt_stream(text): + if isinstance(event, TextChunk): + chunks.append(event.text) + return "".join(chunks) + + async def prompt_stream(self, text: str) -> AsyncIterator[ACPEvent]: + """Send one tick message and yield ACPEvents until the turn ends.""" + assert self._started and self._session_id, "Client not started" + + if self._bootstrap_pending and self.memory_bootstrap: + text = ( + "[LOCAL LEARNINGS BOOTSTRAP — from Condor learnings.md; " + "also curate /mnt/memory]\n" + f"{self.memory_bootstrap}\n\n{text}" + ) + self._bootstrap_pending = False + + events_api = self._sdk.beta.sessions.events + + # Open the stream BEFORE sending so no events are missed; then gate on + # our user.message event id so replayed history (if any) is skipped. + stream = events_api.stream(self._session_id) + if inspect.isawaitable(stream): + stream = await stream + + queue: asyncio.Queue[Any] = asyncio.Queue() + _SENTINEL = object() + + async def _pump() -> None: + try: + async for ev in stream: + queue.put_nowait(ev) + except asyncio.CancelledError: + raise + except Exception as e: + log.warning("Managed session stream error: %s", e) + finally: + queue.put_nowait(_SENTINEL) + + pump_task = asyncio.create_task(_pump()) + + try: + resp = await events_api.send( + self._session_id, + events=[ + {"type": "user.message", "content": [{"type": "text", "text": text}]} + ], + ) + sent_id = "" + data = getattr(resp, "data", None) or [] + if data: + sent_id = getattr(data[0], "id", "") or "" + + loop = asyncio.get_event_loop() + start_time = loop.time() + seen_sent = not sent_id # no id -> process everything + + while True: + try: + ev = await asyncio.wait_for(queue.get(), timeout=30) + except asyncio.TimeoutError: + elapsed = loop.time() - start_time + if not seen_sent and elapsed > 60: + # Echo of our user.message never arrived -- stop gating + # rather than skipping the whole turn. + log.warning("user.message echo not seen after %.0fs; processing all events", elapsed) + seen_sent = True + yield Heartbeat(elapsed_seconds=elapsed) + continue + + if ev is _SENTINEL: + yield PromptDone(stop_reason="disconnected") + return + + ev_type = getattr(ev, "type", "") + ev_id = getattr(ev, "id", "") + + if not seen_sent: + if ev_id == sent_id: + seen_sent = True + continue + + done = False + async for out in self._handle_event(ev, ev_type): + yield out + if isinstance(out, PromptDone): + done = True + if done: + return + finally: + pump_task.cancel() + try: + await pump_task + except (asyncio.CancelledError, Exception): + pass + close = getattr(stream, "close", None) + if close: + try: + result = close() + if inspect.isawaitable(result): + await result + except Exception: + pass + + # ------------------------------------------------------------------ + # Event mapping + # ------------------------------------------------------------------ + + async def _handle_event(self, ev: Any, ev_type: str) -> AsyncIterator[ACPEvent]: + if ev_type == "agent.message": + text = self._blocks_text(getattr(ev, "content", None)) + if text: + yield TextChunk(text=text) + + elif ev_type == "agent.thinking": + # Thinking events carry no text in the Managed Agents stream; + # surface a marker so the UI shows activity. + yield ThoughtChunk(text="(thinking)") + + elif ev_type == "agent.custom_tool_use": + async for out in self._handle_custom_tool(ev): + yield out + + elif ev_type == "agent.tool_use": + # Built-in sandbox tools (bash, read, write -- incl. /mnt/memory) + yield ToolCallEvent( + tool_call_id=getattr(ev, "id", ""), + title=getattr(ev, "name", "tool"), + status="in_progress", + kind="other", + input=getattr(ev, "input", None), + ) + + elif ev_type == "agent.tool_result": + output = self._blocks_text(getattr(ev, "content", None)) + yield ToolCallUpdate( + tool_call_id=getattr(ev, "tool_use_id", "") or "", + status="failed" if getattr(ev, "is_error", False) else "completed", + output=output[:_SNAPSHOT_OUTPUT_CHARS] if output else None, + ) + + elif ev_type == "session.status_idle": + stop = getattr(getattr(ev, "stop_reason", None), "type", "end_turn") + if stop == "requires_action": + return # waiting on a custom tool result we already sent + if stop == "retries_exhausted": + yield TextChunk(text="(session retries exhausted)") + yield PromptDone(stop_reason="error") + return + yield PromptDone(stop_reason="end_turn") + + elif ev_type == "session.error": + err = getattr(ev, "error", None) + msg = getattr(err, "message", None) or getattr(err, "type", "unknown") + yield TextChunk(text=f"(managed session error: {msg})") + yield PromptDone(stop_reason="error") + + elif ev_type in ("session.status_terminated", "session.deleted"): + yield PromptDone(stop_reason="disconnected") + + # All other event types (status_running, spans, threads, compaction, + # echoes of our own user.* events) are ignored. + + async def _handle_custom_tool(self, ev: Any) -> AsyncIterator[ACPEvent]: + tool_use_id = getattr(ev, "id", "") + name = getattr(ev, "name", "") + tool_input = getattr(ev, "input", None) or {} + + # Same risk gate as the ACP / pydantic-ai paths + if self.permission_callback: + tool_call_info = {"tool": name, "title": name, "input": tool_input} + options = [ + {"optionId": "allow", "kind": "allow_once"}, + {"optionId": "deny", "kind": "deny"}, + ] + result = await self.permission_callback(tool_call_info, options) + outcome = result.get("outcome", {}) + if isinstance(outcome, dict) and outcome.get("outcome") == "cancelled": + yield ToolCallEvent( + tool_call_id=tool_use_id, + title=name, + status="blocked", + kind="mcp", + input=tool_input, + ) + await self._send_tool_result( + tool_use_id, _blocked_result_text(result), True + ) + return + + yield ToolCallEvent( + tool_call_id=tool_use_id, + title=name, + status="in_progress", + kind="mcp", + input=tool_input, + ) + + output, is_error = await self._bridge.call(name, tool_input) + await self._send_tool_result(tool_use_id, output, is_error) + + yield ToolCallUpdate( + tool_call_id=tool_use_id, + status="failed" if is_error else "completed", + output=output[:_SNAPSHOT_OUTPUT_CHARS], + ) + + async def _send_tool_result(self, tool_use_id: str, text: str, is_error: bool) -> None: + try: + await self._sdk.beta.sessions.events.send( + self._session_id, + events=[ + { + "type": "user.custom_tool_result", + "custom_tool_use_id": tool_use_id, + "content": [{"type": "text", "text": text or "(empty result)"}], + "is_error": is_error, + } + ], + ) + except Exception: + log.exception("Failed to send custom tool result for %s", tool_use_id) + + @staticmethod + def _blocks_text(blocks: Any) -> str: + if not blocks: + return "" + parts = [] + for block in blocks: + text = getattr(block, "text", None) + if text: + parts.append(text) + return "".join(parts) diff --git a/condor/acp/managed_tools.py b/condor/acp/managed_tools.py new file mode 100644 index 00000000..7fbd40e1 --- /dev/null +++ b/condor/acp/managed_tools.py @@ -0,0 +1,164 @@ +"""MCP-to-custom-tool bridge for Claude Managed Agents. + +Spawns the same stdio MCP servers Condor already uses (mcp-hummingbot, +condor), lists their tools, and exposes them as Managed Agents *custom tool* +definitions. When the hosted agent emits a custom tool_use event, the +ManagedAgentClient dispatches it here -- so trade execution, credentials, +and risk gating all stay on the local machine. +""" + +from __future__ import annotations + +import json +import logging +from contextlib import AsyncExitStack +from typing import Any + +log = logging.getLogger(__name__) + +# Hard cap on tool result text returned to the hosted session. +MAX_RESULT_CHARS = 200_000 + +# The Managed Agents API caps custom tool descriptions at 1024 chars. +MAX_DESCRIPTION_CHARS = 1024 + +_EMPTY_SCHEMA = {"type": "object", "properties": {}} + + +class McpToolBridge: + """Local MCP servers exposed as Managed Agents custom tools.""" + + def __init__( + self, + server_configs: list[dict[str, Any]] | None = None, + working_dir: str | None = None, + ): + self.server_configs = server_configs or [] + self.working_dir = working_dir + self._exit_stack: AsyncExitStack | None = None + # tool name -> (server_name, session) + self._routes: dict[str, tuple[str, Any]] = {} + self._tool_defs: list[dict[str, Any]] = [] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Spawn stdio MCP servers and discover their tools.""" + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import get_default_environment, stdio_client + + self._exit_stack = AsyncExitStack() + + for cfg in self.server_configs: + name = cfg.get("name", cfg.get("command", "mcp")) + env = dict(get_default_environment()) + for entry in cfg.get("env", []): + if isinstance(entry, dict): + env[entry["name"]] = entry["value"] + + params = StdioServerParameters( + command=cfg["command"], + args=cfg.get("args", []), + env=env, + cwd=self.working_dir, + ) + try: + read, write = await self._exit_stack.enter_async_context( + stdio_client(params) + ) + session = await self._exit_stack.enter_async_context( + ClientSession(read, write) + ) + await session.initialize() + result = await session.list_tools() + self._register(name, session, result.tools) + except Exception: + log.exception("MCP bridge: failed to start server '%s'", name) + + log.info( + "MCP bridge ready: %d tools from %d servers", + len(self._tool_defs), len(self.server_configs), + ) + + async def stop(self) -> None: + if self._exit_stack: + try: + await self._exit_stack.aclose() + except Exception: + log.exception("MCP bridge: error closing servers") + self._exit_stack = None + self._routes.clear() + self._tool_defs.clear() + + # ------------------------------------------------------------------ + # Tool registry + # ------------------------------------------------------------------ + + def _register(self, server_name: str, session: Any, tools: list[Any]) -> None: + """Register a server's tools. First registration wins on name collision.""" + for tool in tools: + if tool.name in self._routes: + log.warning( + "MCP bridge: tool '%s' from '%s' shadowed by earlier server", + tool.name, server_name, + ) + continue + schema = getattr(tool, "inputSchema", None) or dict(_EMPTY_SCHEMA) + description = getattr(tool, "description", None) or "" + if len(description) > MAX_DESCRIPTION_CHARS: + description = description[: MAX_DESCRIPTION_CHARS - 2].rstrip() + " …" + self._routes[tool.name] = (server_name, session) + self._tool_defs.append( + { + "type": "custom", + "name": tool.name, + "description": description, + "input_schema": schema, + } + ) + + @property + def custom_tool_defs(self) -> list[dict[str, Any]]: + return list(self._tool_defs) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + async def call(self, name: str, arguments: dict[str, Any]) -> tuple[str, bool]: + """Call a tool on its owning MCP server. + + Returns (output_text, is_error). + """ + route = self._routes.get(name) + if not route: + return f"Unknown tool: {name}", True + + server_name, session = route + try: + result = await session.call_tool(name, arguments or {}) + except Exception as e: + log.exception("MCP bridge: tool '%s' on '%s' failed", name, server_name) + return f"Tool call failed: {e}", True + + text = self._render_content(result) + is_error = bool(getattr(result, "isError", False)) + if len(text) > MAX_RESULT_CHARS: + text = text[:MAX_RESULT_CHARS] + "\n... (truncated)" + return text, is_error + + @staticmethod + def _render_content(result: Any) -> str: + parts: list[str] = [] + for block in getattr(result, "content", None) or []: + text = getattr(block, "text", None) + if text is not None: + parts.append(text) + else: + try: + parts.append(json.dumps(block.model_dump(), default=str)) + except Exception: + parts.append(str(block)) + return "\n".join(parts) diff --git a/condor/trading_agent/README.md b/condor/trading_agent/README.md index d3528ef8..35473802 100644 --- a/condor/trading_agent/README.md +++ b/condor/trading_agent/README.md @@ -110,6 +110,36 @@ trading_agents/ `auto_approve_with_risk_check(...)` is wired in as the ACP permission callback. Every tool call the LLM tries to make is intercepted: trading-side tools are checked against `RiskLimits` (max exposure, max drawdown, max open executors); read-only tools are auto-approved. The agent literally cannot exceed its limits — the framework refuses on its behalf. +#### Claude Managed Agents (`claude-managed`) + +`agent_key: claude-managed` (optionally `claude-managed:`, or pin via `default_config.model`) runs the brain on Anthropic's hosted Managed Agents harness instead of a local subprocess: + +- **Persistent session** — every tick is a `user.message` in the same hosted conversation, so the agent natively remembers earlier ticks. The harness handles context compaction and prompt caching; Condor only sends the *dynamic* state per tick (`build_managed_tick_prompt`), while rules/strategy/routines live in the agent's system prompt (`build_managed_system_prompt`). +- **Cross-session memory** — a workspace memory store is mounted at `/mnt/memory` inside the agent's sandbox and survives across sessions and restarts. The agent curates its own `playbooks.md` / `mistakes.md` / `regimes.md`; this is the self-learning loop. +- **Local execution, hosted reasoning** — trading tools are exposed as *custom tools*. The hosted agent emits structured tool requests; `McpToolBridge` (`condor/acp/managed_tools.py`) executes them on the local machine through the same MCP servers, gated by the same `auto_approve_with_risk_check` callback. Exchange credentials never leave the machine, and dry-run / risk blocking behaves identically to the other providers. +- **State** — agent/session/store IDs persist in `trading_agents/{slug}/state/managed_agent.json`. The agent is recreated automatically when the model, system prompt, or tool set changes (fingerprint check). `dry_run` / `run_once` experiments use ephemeral sessions (so they never pollute the live conversation) but still share the memory store. + +Requires `ANTHROPIC_API_KEY` and `anthropic>=0.109`. + +#### Cursor SDK agents (`cursor-managed`) + +`agent_key: cursor-managed` (optionally `cursor-managed:composer-2.5`) runs the brain on the **Cursor SDK** local bridge with a persistent hosted conversation, similar to managed agents but with filesystem memory under `trading_agents/{slug}/memory/` instead of `/mnt/memory`. + +- **Persistent conversation** — one Cursor agent id per Condor *session*; ticks are follow-up messages. Starting a **new** Condor session auto-rotates the Cursor agent so you get a fresh remote conversation. +- **Local execution** — trading tools use the same `McpToolBridge` + MCP servers as `claude-managed`. +- **State** — agent id + fingerprint persist in `trading_agents/{slug}/state/cursor_agent.json`. + +Requires `CURSOR_API_KEY` and `cursor-sdk>=0.1.8`. + +**Troubleshooting (cursor-managed)** + +| Symptom | Likely cause | Fix | +|---------|----------------|-----| +| `internal: internal error` | Stale or wedged remote Cursor agent | Stop agent, start new session (auto-rotates); or restart Condor | +| `Bridge request failed: ConnectError` | Dead local `cursor-sdk-bridge` process | Restart Condor; only one session per slug | +| Tick errors every 5 min, no snapshots | Same broken agent/bridge reused | Engine rotates after 2 consecutive Cursor errors | +| `409 already running` | Second start while session active | Stop the existing session first | + --- ## 3. The executor / position-hold pattern in detail diff --git a/condor/trading_agent/engine.py b/condor/trading_agent/engine.py index 3a62f23f..f15841e4 100644 --- a/condor/trading_agent/engine.py +++ b/condor/trading_agent/engine.py @@ -25,10 +25,28 @@ ToolCallEvent, ToolCallUpdate, ) +from condor.acp.cursor_agent_client import ( + CursorAgentClient, + is_cursor_agent_key, + is_cursor_provider_error, + is_persistent_provider_key, + reset_cursor_bridge, + resolve_cursor_model, +) +from condor.acp.managed_agent_client import ( + ManagedAgentClient, + is_managed_agent_key, + resolve_managed_model, +) from condor.acp.pydantic_ai_client import PydanticAIClient, is_pydantic_ai_model from .journal import JournalManager, next_experiment_number, next_session_number -from .prompts import build_tick_prompt +from .prompts import ( + build_cursor_system_prompt, + build_managed_system_prompt, + build_managed_tick_prompt, + build_tick_prompt, +) from .risk import RiskEngine, RiskLimits, auto_approve_with_risk_check from .strategy import Strategy from .providers import ProviderRegistry @@ -38,6 +56,13 @@ # Module-level registry of running engines _engines: dict[str, "TickEngine"] = {} +# After this many consecutive tick timeouts, rotate the managed session +# (wedged hosted sessions otherwise dead-loop silently) and alert the user. +ROTATE_AFTER_CONSECUTIVE_TIMEOUTS = 3 + +# Cursor SDK bridge / remote-agent failures — rotate sooner than timeouts. +ROTATE_AFTER_CONSECUTIVE_CURSOR_ERRORS = 2 + class _NullTracker: """Stub tracker for experiments (no journal).""" @@ -81,6 +106,9 @@ class TickEngine: _last_skill_data: dict[str, Any] = field(default_factory=dict, init=False) _pending_directives: list[str] = field(default_factory=list, init=False) _cached_routines_section: str | None = field(default=None, init=False, repr=False) + _consecutive_timeouts: int = field(default=0, init=False) + _consecutive_cursor_errors: int = field(default=0, init=False) + _memory_bootstrap_sent: bool = field(default=False, init=False) def __post_init__(self): agent_dir = self.strategy.agent_dir @@ -123,6 +151,21 @@ async def start(self, bot=None) -> None: """Start the tick loop as an asyncio task.""" if self._running: return + + agent_key = self.config.get("agent_key") or self.strategy.agent_key + if not self.is_experiment and is_cursor_agent_key(agent_key): + rotated = await CursorAgentClient.rotate_persisted_agent( + self.strategy.agent_dir + ) + if rotated: + log.info( + "TickEngine %s: rotated Cursor agent %s for new session_%d", + self.agent_id, + rotated, + self.session_num, + ) + await reset_cursor_bridge() + self._running = True self._bot = bot self._task = asyncio.create_task(self._loop()) @@ -182,6 +225,7 @@ async def _loop(self) -> None: try: await self._tick() self._last_error = "" + self._consecutive_cursor_errors = 0 except asyncio.CancelledError: raise except Exception as e: @@ -190,6 +234,7 @@ async def _loop(self) -> None: if self.journal: self.journal.append_error(str(e)) await self._notify(f"Agent {self.agent_id} tick error: {e}") + await self._handle_cursor_error_streak(e) # Single-tick modes: stop after first tick if mode in ("dry_run", "run_once"): @@ -272,18 +317,33 @@ async def _tick(self) -> None: self._cached_routines_section = "" next_tick = self.journal.tick_count + 1 if self.journal else 1 - prompt = build_tick_prompt( - strategy=self.strategy, - config=self.config, - core_data=core_data_summaries, - learnings=learnings, - summary=summary, - recent_decisions=recent_decisions, - risk_state=risk_state.to_dict(), - tick_number=next_tick, - agent_id=self.agent_id, - cached_routines_section=self._cached_routines_section or None, - ) + agent_key = self.config.get("agent_key") or self.strategy.agent_key + if is_persistent_provider_key(agent_key): + # Managed / Cursor sessions are persistent: static content lives in + # the system prompt; per-tick message only carries dynamic state. + prompt = build_managed_tick_prompt( + config=self.config, + core_data=core_data_summaries, + learnings=learnings, + risk_state=risk_state.to_dict(), + tick_number=next_tick, + agent_id=self.agent_id, + summary=summary, + recent_decisions=recent_decisions, + ) + else: + prompt = build_tick_prompt( + strategy=self.strategy, + config=self.config, + core_data=core_data_summaries, + learnings=learnings, + summary=summary, + recent_decisions=recent_decisions, + risk_state=risk_state.to_dict(), + tick_number=next_tick, + agent_id=self.agent_id, + cached_routines_section=self._cached_routines_section or None, + ) # Inject pending user directives if self._pending_directives: @@ -299,6 +359,7 @@ async def _tick(self) -> None: response_chunks: list[str] = [] tool_calls: list[dict[str, Any]] = [] tool_call_map: dict[str, dict[str, Any]] = {} + tick_timed_out = False await acp_client.start() try: @@ -335,11 +396,14 @@ async def _tick(self) -> None: if event.output: tc["output"] = event.output except asyncio.TimeoutError: + tick_timed_out = True log.warning("TickEngine %s: ACP prompt timed out", self.agent_id) response_chunks.append("(timed out)") finally: await acp_client.stop() + await self._handle_timeout_streak(tick_timed_out) + response_text = "".join(response_chunks) tick_duration = time.time() - self._last_tick_at @@ -420,8 +484,10 @@ async def _collect_stream(self, acp_client: ACPClient, prompt: str): # Client factory # ------------------------------------------------------------------ - async def _create_client(self) -> "ACPClient | PydanticAIClient": - """Build an ACP or PydanticAI client (does NOT start it).""" + async def _create_client( + self, + ) -> "ACPClient | PydanticAIClient | ManagedAgentClient | CursorAgentClient": + """Build an ACP, PydanticAI, ManagedAgent, or CursorAgent client (does NOT start it).""" from handlers.agents._shared import ( build_mcp_servers_for_agent, build_mcp_servers_for_session, @@ -446,6 +512,64 @@ async def _create_client(self) -> "ACPClient | PydanticAIClient": permission_cb = auto_approve_with_risk_check(self.risk, risk_state, execution_mode=mode) agent_key = self.config.get("agent_key") or self.strategy.agent_key + + if is_managed_agent_key(agent_key): + from .prompts import read_learnings_bootstrap + + memory_bootstrap = "" + if not self._memory_bootstrap_sent and self.journal and self.journal.tick_count == 0: + memory_bootstrap = read_learnings_bootstrap(self.strategy.agent_dir) + if memory_bootstrap: + self._memory_bootstrap_sent = True + # Claude Managed Agents: persistent hosted session + memory store. + # Trading tools execute locally via the MCP bridge, gated by the + # same permission callback. Experiments get ephemeral sessions so + # dry runs never pollute the live conversation. + model = resolve_managed_model(agent_key, self.config.get("model") or "") + system_prompt = build_managed_system_prompt( + self.strategy, self.config, + routines_section=self._cached_routines_section or "", + ) + return ManagedAgentClient( + model=model, + system_prompt=system_prompt, + agent_name=self.config.get("managed_agent_name") or self.strategy.name, + slug=self.strategy.slug, + agent_dir=self.strategy.agent_dir, + mcp_servers=mcp_servers, + permission_callback=permission_cb, + persist_session=not self.is_experiment, + working_dir=get_project_dir(), + memory_bootstrap=memory_bootstrap, + ) + + if is_cursor_agent_key(agent_key): + from .prompts import read_learnings_bootstrap + + memory_bootstrap = "" + if not self._memory_bootstrap_sent and self.journal and self.journal.tick_count == 0: + memory_bootstrap = read_learnings_bootstrap(self.strategy.agent_dir) + if memory_bootstrap: + self._memory_bootstrap_sent = True + model = resolve_cursor_model(agent_key, self.config.get("model") or "") + system_prompt = build_cursor_system_prompt( + self.strategy, + self.config, + routines_section=self._cached_routines_section or "", + ) + return CursorAgentClient( + model=model, + system_prompt=system_prompt, + agent_name=self.config.get("managed_agent_name") or self.strategy.name, + slug=self.strategy.slug, + agent_dir=self.strategy.agent_dir, + mcp_servers=mcp_servers, + permission_callback=permission_cb, + persist_session=not self.is_experiment, + working_dir=get_project_dir(), + memory_bootstrap=memory_bootstrap, + ) + use_pydantic_ai = is_pydantic_ai_model(agent_key) if use_pydantic_ai: @@ -510,6 +634,94 @@ async def _get_client(self): log.exception("Failed to get API client for agent %s", self.agent_id) return None + async def _handle_timeout_streak(self, timed_out: bool) -> None: + """Track consecutive tick timeouts; rotate the managed session and alert + after ROTATE_AFTER_CONSECUTIVE_TIMEOUTS. + + A wedged hosted session (stops echoing user messages) otherwise + dead-loops silently. + """ + if not timed_out: + self._consecutive_timeouts = 0 + return + + self._consecutive_timeouts += 1 + n = self._consecutive_timeouts + if n < ROTATE_AFTER_CONSECUTIVE_TIMEOUTS: + return + + rotated = "" + agent_key = self.config.get("agent_key") or self.strategy.agent_key + if is_managed_agent_key(agent_key): + try: + rotated = await ManagedAgentClient.rotate_persisted_session( + self.strategy.agent_dir + ) + except Exception: + log.exception("Session rotation failed for %s", self.agent_id) + elif is_cursor_agent_key(agent_key): + try: + rotated = await CursorAgentClient.rotate_persisted_agent( + self.strategy.agent_dir + ) + except Exception: + log.exception("Cursor agent rotation failed for %s", self.agent_id) + + log.warning( + "TickEngine %s: %d consecutive timeouts%s", + self.agent_id, n, + f"; rotated managed session {rotated}" if rotated else "", + ) + await self._notify( + f"⚠️ Agent {self.agent_id}: {n} consecutive LLM tick timeouts" + + ( + f" — rotated managed session {rotated}; a fresh session starts next tick." + if rotated + else " — check the LLM provider." + ) + ) + self._consecutive_timeouts = 0 + + async def _handle_cursor_error_streak(self, exc: Exception) -> None: + """Track consecutive Cursor SDK failures; rotate bridge + agent after threshold.""" + if not is_cursor_provider_error(exc): + self._consecutive_cursor_errors = 0 + return + + agent_key = self.config.get("agent_key") or self.strategy.agent_key + if not is_cursor_agent_key(agent_key): + return + + self._consecutive_cursor_errors += 1 + n = self._consecutive_cursor_errors + if n < ROTATE_AFTER_CONSECUTIVE_CURSOR_ERRORS: + return + + rotated = "" + try: + await reset_cursor_bridge() + rotated = await CursorAgentClient.rotate_persisted_agent( + self.strategy.agent_dir + ) + except Exception: + log.exception("Cursor bridge/agent rotation failed for %s", self.agent_id) + + log.warning( + "TickEngine %s: %d consecutive Cursor provider errors%s", + self.agent_id, + n, + f"; rotated agent {rotated}" if rotated else "", + ) + await self._notify( + f"⚠️ Agent {self.agent_id}: {n} consecutive Cursor SDK errors" + + ( + f" — rotated agent {rotated}; fresh conversation next tick." + if rotated + else " — check CURSOR_API_KEY and network." + ) + ) + self._consecutive_cursor_errors = 0 + async def _notify(self, message: str) -> None: """Send a notification to the user via Telegram.""" if hasattr(self, "_bot") and self._bot: diff --git a/condor/trading_agent/prompts.py b/condor/trading_agent/prompts.py index 9ca54805..ce85f2ff 100644 --- a/condor/trading_agent/prompts.py +++ b/condor/trading_agent/prompts.py @@ -9,6 +9,7 @@ from typing import Any +from .risk import format_drawdown_display from .strategy import Strategy BASE_PROMPT_LIVE = """\ @@ -233,3 +234,253 @@ def build_tick_prompt( sections.append(f"[RECENT DECISIONS — last 3 snapshots]\n{recent_decisions}") return "\n\n".join(sections) + + +# ══════════════════════════════════════════════════════════════════════ +# Managed provider prompts +# +# Persistent-session providers (e.g. Claude Managed Agents) split the +# prompt in two: a static system prompt set once on the hosted agent +# (build_managed_system_prompt) and a lean per-tick message carrying only +# dynamic state (build_managed_tick_prompt). +# ══════════════════════════════════════════════════════════════════════ + +# Config keys excluded from the managed tick prompt's [CURRENT CONFIG] +# (shown elsewhere or internal noise) +_MANAGED_CONFIG_EXCLUDE = { + "trading_context", "risk_limits", # shown in dedicated sections + "agent_key", "server_name", "frequency_sec", "execution_mode", "model", + "margin_quote", "leverage", # shown in [SIZING] +} + + +def build_sizing_section(config: dict[str, Any]) -> str: + """Render [SIZING] from margin_quote x leverage. Empty when margin unset.""" + try: + margin = float(config.get("margin_quote", 0) or 0) + leverage = max(int(config.get("leverage", 1) or 1), 1) + except (TypeError, ValueError): + return "" + if margin <= 0: + return "" + notional = margin * leverage + return "\n".join([ + "[SIZING]", + f"Margin per trade: ${margin:,.2f}", + f"Leverage: {leverage}x", + f"Notional per trade: ${notional:,.2f} (margin x leverage)", + f"Executor sizing: amount = {notional:.2f} / last_close (BASE units, 6 decimals); pass leverage={leverage}.", + f"Price-move conversion: X% on margin = X/{leverage}% price move " + f"(e.g. +10% on margin = {10 / leverage:.4f}% price).", + ]) + + +MANAGED_TOOLS_NOTE = """\ +TOOLS: +- All trading tools (get_market_data, manage_executors, manage_routines, \ +trading_agent_journal_write, send_notification, ...) are available natively. \ +Call them directly by name. +- Tool calls are executed by Condor on the local machine and gated by its \ +risk engine. A blocked call returns an error result — do not retry it; \ +journal why instead. +""" + +CURSOR_MEMORY_PROTOCOL = """\ +MEMORY (filesystem): +- Long-term memory lives under your agent directory in ``memory/``: + - memory/playbooks.md — setups that worked, with exact indicator values at entry. + - memory/mistakes.md — every loss or error: state at entry, what happened, prevention rule. + - memory/regimes.md — regime-specific behavior notes and SL calibration. +- Review memory/ at session start (read via manage_routines or journal context). +- Update memory/ after every closed position and at session end. Keep entries dated and numeric. +- Memory complements the journal: ALWAYS also write journal entries via trading_agent_journal_write. +- ``learnings.md`` is injected each tick for novelty filtering; curate durable patterns into memory/. +""" + +MANAGED_MEMORY_PROTOCOL = """\ +MEMORY (/mnt/memory): +- A persistent memory directory is mounted at /mnt/memory. It survives across \ +sessions — it is YOUR long-term memory. Review it before your first trade \ +decision of a session. +- Maintain these files (create them if missing): + - playbooks.md — setups that work, entry/exit criteria, observed win rates. + - mistakes.md — every loss or error: what happened, root cause, the rule \ +that prevents a repeat. + - regimes.md — regime-specific behavior notes (trend vs chop vs volatile). +- Update memory after every closed position and at the end of each session. \ +Keep entries concise, factual, dated. +- Memory complements the journal, it does not replace it: ALWAYS also write \ +journal entries via trading_agent_journal_write (local audit trail). +""" + + +def build_managed_system_prompt( + strategy: Strategy, + config: dict[str, Any], + routines_section: str = "", + memory_protocol: str | None = None, +) -> str: + """Build the static system prompt for a Claude Managed Agent. + + Unlike the per-tick ACP prompt, this is set once on the hosted agent and + cached by the harness. Per-tick dynamic data goes through + build_managed_tick_prompt() instead. + """ + is_dry_run = config.get("execution_mode", "loop") == "dry_run" + base_prompt = BASE_PROMPT_DRY_RUN if is_dry_run else BASE_PROMPT_LIVE + + sections: list[str] = [ + base_prompt, + BASE_PROMPT_COMMON, + MANAGED_TOOLS_NOTE, + memory_protocol or MANAGED_MEMORY_PROTOCOL, + f"[STRATEGY INSTRUCTIONS]\n{strategy.instructions}", + ] + if routines_section: + sections.append(routines_section) + + trading_context = config.get("trading_context", "") + if trading_context: + sections.append( + "[SESSION CONTEXT]\n" + "The user provided the following natural language context for this " + "trading session. Use this to guide your market selection, risk " + f"appetite, and trading style:\n\n{trading_context}" + ) + return "\n\n".join(sections) + + +def build_cursor_system_prompt( + strategy: Strategy, + config: dict[str, Any], + routines_section: str = "", +) -> str: + """Static system prompt for Cursor SDK agents.""" + return build_managed_system_prompt( + strategy, + config, + routines_section=routines_section, + memory_protocol=CURSOR_MEMORY_PROTOCOL, + ) + + +def _trim_learnings_for_managed(text: str, max_lines: int = 30) -> str: + """Cap learnings injected into the lean per-tick managed prompt.""" + if not text: + return "" + lines = text.splitlines() + if len(lines) <= max_lines: + return text + return "\n".join(lines[-max_lines:]) + + +def _trim_recent_decisions_for_managed( + text: str, + max_entries: int = 5, + max_chars: int = 2000, +) -> str: + """Keep only the last N recent-decision entries for the managed prompt.""" + if not text: + return "" + entries = [e for e in text.split("\n- ") if e.strip()] + if entries and not entries[0].startswith("-"): + entries[0] = entries[0].lstrip("- ") + trimmed = entries[-max_entries:] if len(entries) > max_entries else entries + joined = "\n- ".join(trimmed) + if trimmed: + joined = "- " + joined if not joined.startswith("-") else joined + return joined[:max_chars] + + +def read_learnings_bootstrap(agent_dir: Any, max_lines: int = 40) -> str: + """Last N lines of learnings.md for managed-agent memory bootstrap.""" + from pathlib import Path + + path = Path(agent_dir) / "learnings.md" + if not path.exists(): + return "" + lines = path.read_text(encoding="utf-8").splitlines() + tail = "\n".join(lines[-max_lines:]).strip() + return tail + + +def build_managed_tick_prompt( + config: dict[str, Any], + core_data: dict[str, str], + learnings: str, + risk_state: dict[str, Any], + tick_number: int = 1, + agent_id: str = "", + summary: str = "", + recent_decisions: str = "", + extra_sections: list[str] | None = None, +) -> str: + """Build the lean per-tick message for a Claude Managed Agent. + + The hosted session is persistent, so static content (rules, strategy, + routines) lives in the system prompt and the conversation itself carries + tick-to-tick context. Only the dynamic state goes here. + """ + execution_mode = config.get("execution_mode", "loop") + is_dry_run = execution_mode == "dry_run" + + tick_info = ( + f"[TICK INFO]\nThis is tick #{tick_number}. " + "Use this number in journal entries and notifications." + ) + if agent_id: + tick_info += f"\nAgent ID: {agent_id}" + if not is_dry_run: + tick_info += ( + f'\nPass controller_id="{agent_id}" as a TOP-LEVEL arg to ' + "manage_executors (not inside executor_config)." + ) + sections: list[str] = [tick_info] + + if execution_mode == "run_once": + sections.append( + "[EXECUTION MODE — RUN ONCE]\n" + "Single-tick session with LIVE execution. The engine will stop after " + "this tick. Make your best move now — there will be no follow-up ticks." + ) + + config_lines = ["[CURRENT CONFIG]"] + for k, v in config.items(): + if k in _MANAGED_CONFIG_EXCLUDE: + continue + config_lines.append(f"{k}: {v}") + sections.append("\n".join(config_lines)) + + rs = risk_state + dd_display = format_drawdown_display(rs) + sections.append("\n".join([ + "[RISK STATE]", + f"Position Size: ${rs.get('total_exposure', 0):.2f} / ${rs.get('max_position_size', 500):.2f} limit", + f"Open Executors: {rs.get('executor_count', 0)} / {rs.get('max_open_executors', 5)} limit", + f"Drawdown: {dd_display}", + f"Status: {'BLOCKED - ' + rs.get('block_reason', '') if rs.get('is_blocked') else 'ACTIVE'}", + ])) + + sizing = build_sizing_section(config) + if sizing: + sections.append(sizing) + + for name, data_summary in core_data.items(): + sections.append(f"[CORE DATA - {name}]\n{data_summary}") + + if extra_sections: + sections.extend(extra_sections) + + learnings = _trim_learnings_for_managed(learnings) + recent_decisions = _trim_recent_decisions_for_managed(recent_decisions) + + if learnings: + sections.append( + f"[LEARNINGS — do NOT repeat these, only add genuinely new insights]\n{learnings}" + ) + if summary: + sections.append(f"[CURRENT STATUS]\n{summary}") + if recent_decisions: + sections.append(f"[RECENT DECISIONS — last 5 entries]\n{recent_decisions}") + + return "\n\n".join(sections) diff --git a/condor/trading_agent/risk.py b/condor/trading_agent/risk.py index 2e2e3271..7eca3ec4 100644 --- a/condor/trading_agent/risk.py +++ b/condor/trading_agent/risk.py @@ -13,6 +13,30 @@ log = logging.getLogger(__name__) +BLOCK_RESULT_PREFIX = "BLOCKED by the Condor risk engine" + + +def format_block_result(block_reason: str | None) -> str: + """Human-readable tool result when the risk gate cancels a mutating call.""" + detail = block_reason or "risk limits or dry-run mode" + return ( + f"{BLOCK_RESULT_PREFIX} ({detail}). " + "Do not retry this action this tick; journal why instead." + ) + + +def format_drawdown_display(risk_state: dict[str, Any]) -> str: + """Human-readable drawdown line for prompts and journal snapshots.""" + max_dd = risk_state.get("max_drawdown_pct", -1) + if max_dd < 0: + return "disabled" + dd_pct = float(risk_state.get("drawdown_pct", 0) or 0) + base = f"{dd_pct:.1f}% / {max_dd:.1f}% limit" + ref = float(risk_state.get("drawdown_reference_quote", 0) or 0) + if ref > 0: + return f"{base} (vs ${ref:.2f} margin)" + return base + @dataclass class RiskLimits: diff --git a/condor/web/routes/agents.py b/condor/web/routes/agents.py index d14a2f3e..c9abaa02 100644 --- a/condor/web/routes/agents.py +++ b/condor/web/routes/agents.py @@ -869,6 +869,21 @@ async def start_agent( elif not config_dict.get("trading_context") and strategy.default_trading_context: config_dict["trading_context"] = strategy.default_trading_context + agent_key = config_dict.get("agent_key") or strategy.agent_key + from condor.acp.cursor_agent_client import is_cursor_agent_key + + if is_cursor_agent_key(agent_key): + running = [e for e in _get_engines_for_slug(slug) if e.is_running] + if running: + active = running[0].agent_id + raise HTTPException( + status_code=409, + detail=( + f"Agent '{slug}' is already running as {active}. " + "Stop it before starting a new session." + ), + ) + new_engine = TickEngine( strategy=strategy, config=config_dict, diff --git a/pyproject.toml b/pyproject.toml index 27474e6d..04e20284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "scipy>=1.17.1", "pydantic-ai[mcp]", "beautifulsoup4>=4.14.3", + "anthropic>=0.109.1", + "cursor-sdk>=0.1.8", ] [project.optional-dependencies] diff --git a/tests/test_cursor_agent_client.py b/tests/test_cursor_agent_client.py new file mode 100644 index 00000000..761c906c --- /dev/null +++ b/tests/test_cursor_agent_client.py @@ -0,0 +1,323 @@ +"""Tests for CursorAgentClient -- Cursor SDK provider (faked, no network).""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +from condor.acp.client import PromptDone, TextChunk, ToolCallEvent, ToolCallUpdate +from condor.acp.cursor_agent_client import ( + DEFAULT_CURSOR_MODEL, + CursorAgentClient, + is_cursor_agent_key, + is_cursor_provider_error, + is_recoverable_cursor_error, + reset_cursor_bridge, + resolve_cursor_model, +) + + +class InternalServerError(Exception): + """Stand-in for cursor_sdk.errors.InternalServerError in tests.""" + + +class _FakeRun: + def __init__(self, messages, status="finished", result=""): + self._messages = list(messages) + self.status = status + self.result = result + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._messages: + raise StopAsyncIteration + return self._messages.pop(0) + + async def messages(self): + for m in self._messages: + yield m + + async def wait(self): + return SimpleNamespace(status=self.status, result=self.result) + + async def cancel(self): + self.status = "cancelled" + + +class _FakeAgent: + def __init__(self, sdk, fail_first_send: bool = False): + self.sdk = sdk + self.agent_id = sdk.next_agent_id() + self._send_count = 0 + self.fail_first_send = fail_first_send + + async def send(self, text): + self.sdk.sent.append(text) + self._send_count += 1 + if "SYSTEM INSTRUCTIONS" in text: + return _FakeRun([], status="finished", result="READY") + if self.fail_first_send: + self.fail_first_send = False + raise InternalServerError("internal: internal error") + return _FakeRun(list(self.sdk.stream_messages)) + + async def delete(self): + self.sdk.deleted.append(self.agent_id) + + +class _FakeAgentsAPI: + def __init__(self, sdk): + self.sdk = sdk + + async def create(self, options): + self.sdk.created.append(options) + agent = _FakeAgent(self.sdk) + self.sdk._agents[agent.agent_id] = agent + return agent + + async def resume(self, agent_id, options): + self.sdk.resumed.append((agent_id, options)) + agent = self.sdk._agents.get(agent_id) or _FakeAgent(self.sdk) + agent.agent_id = agent_id + self.sdk._agents[agent_id] = agent + return agent + + +class _FakeSDKClient: + def __init__(self, stream_messages=None): + self.stream_messages = stream_messages or [] + self.created: list = [] + self.resumed: list = [] + self.sent: list = [] + self.deleted: list = [] + self._agents: dict = {} + self._counter = 0 + self.agents_api = _FakeAgentsAPI(self) + + @property + def agents(self): + return self.agents_api + + def next_agent_id(self): + self._counter += 1 + return f"agent-test-{self._counter:03d}" + + +class _FakeBridge: + def __init__(self): + self.started = False + self.stopped = False + self._tool_defs = [ + { + "type": "custom", + "name": "manage_executors", + "description": "Manage executors", + "input_schema": {"type": "object", "properties": {}}, + } + ] + + async def start(self): + self.started = True + + async def stop(self): + self.stopped = True + + @property + def custom_tool_defs(self): + return self._tool_defs + + async def call(self, name, arguments): + return f"ok:{name}", False + + +def test_resolve_cursor_model(): + assert resolve_cursor_model("cursor-managed", "") == DEFAULT_CURSOR_MODEL + assert resolve_cursor_model("cursor-managed:composer-2.5", "") == "composer-2.5" + assert resolve_cursor_model("cursor-managed", "composer-2.5") == "composer-2.5" + + +def test_is_cursor_agent_key(): + assert is_cursor_agent_key("cursor-managed") + assert not is_cursor_agent_key("claude-managed") + + +def test_prompt_stream_maps_events(tmp_path, monkeypatch): + monkeypatch.setenv("CURSOR_API_KEY", "test-key") + + text_block = SimpleNamespace(type="text", text="FLAT — chop.") + assistant = SimpleNamespace( + type="assistant", + message=SimpleNamespace(content=[text_block]), + ) + tool_running = SimpleNamespace( + type="tool_call", + call_id="tc1", + name="manage_routines", + status="running", + args={"action": "list"}, + ) + tool_done = SimpleNamespace( + type="tool_call", + call_id="tc1", + name="manage_routines", + status="completed", + result="ok", + ) + + sdk = _FakeSDKClient([assistant, tool_running, tool_done]) + bridge = _FakeBridge() + agent_dir = tmp_path / "composer_btc_perp_brain" + agent_dir.mkdir() + (agent_dir / "state").mkdir() + + client = CursorAgentClient( + model="composer-2.5", + system_prompt="Trade BTC.", + agent_name="Composer Brain", + slug="composer_btc_perp_brain", + agent_dir=agent_dir, + persist_session=True, + bridge=bridge, + sdk_client=sdk, + ) + + async def _run(): + await client.start() + events = [] + async for ev in client.prompt_stream("tick 1 data"): + events.append(ev) + await client.stop() + return events + + events = asyncio.run(_run()) + types = [type(e).__name__ for e in events] + assert "TextChunk" in types + assert "ToolCallEvent" in types + assert "ToolCallUpdate" in types + assert types[-1] == "PromptDone" + assert bridge.started and bridge.stopped + state = json.loads((agent_dir / "state" / "cursor_agent.json").read_text()) + assert state.get("agent_id", "").startswith("agent-test-") + + +def test_permission_callback_blocks_tool(tmp_path, monkeypatch): + monkeypatch.setenv("CURSOR_API_KEY", "test-key") + + async def deny(tool_call, options): + return {"outcome": {"outcome": "cancelled"}, "block_reason": "dry-run"} + + bridge = _FakeBridge() + sdk = _FakeSDKClient([]) + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + + client = CursorAgentClient( + model="composer-2.5", + system_prompt="x", + agent_name="T", + slug="t", + agent_dir=agent_dir, + permission_callback=deny, + bridge=bridge, + sdk_client=sdk, + ) + tools = client._build_custom_tools() + assert "manage_executors" in tools + + async def _exec(): + from cursor_sdk import CustomToolContext + + result = await tools["manage_executors"].execute( + {"action": "create"}, + CustomToolContext(tool_call_id="1"), + ) + return result + + result = asyncio.run(_exec()) + assert "BLOCKED" in result + + +def test_rotate_persisted_agent(tmp_path): + agent_dir = tmp_path / "composer" + state_dir = agent_dir / "state" + state_dir.mkdir(parents=True) + path = state_dir / "cursor_agent.json" + path.write_text(json.dumps({"agent_id": "agent-old", "agent_fingerprint": "abc"})) + + old = asyncio.run(CursorAgentClient.rotate_persisted_agent(agent_dir)) + assert old == "agent-old" + state = json.loads(path.read_text()) + assert "agent_id" not in state + + +def test_is_recoverable_cursor_error(): + assert is_recoverable_cursor_error(InternalServerError("internal: internal error")) + assert is_cursor_provider_error("Bridge request failed: ConnectError") + assert not is_recoverable_cursor_error(ValueError("nope")) + + +def test_send_recovers_from_internal_error(tmp_path, monkeypatch): + monkeypatch.setenv("CURSOR_API_KEY", "test-key") + + text_block = SimpleNamespace(type="text", text="FLAT after recovery.") + assistant = SimpleNamespace( + type="assistant", + message=SimpleNamespace(content=[text_block]), + ) + + sdk = _FakeSDKClient([assistant]) + bridge = _FakeBridge() + agent_dir = tmp_path / "composer_btc_perp_brain" + agent_dir.mkdir() + state_dir = agent_dir / "state" + state_dir.mkdir() + + client = CursorAgentClient( + model="composer-2.5", + system_prompt="Trade BTC.", + agent_name="Composer Brain", + slug="composer_btc_perp_brain", + agent_dir=agent_dir, + persist_session=True, + bridge=bridge, + sdk_client=sdk, + ) + fingerprint = client._fingerprint([t["name"] for t in bridge.custom_tool_defs]) + (state_dir / "cursor_agent.json").write_text( + json.dumps( + { + "agent_id": "agent-stale", + "agent_fingerprint": fingerprint, + "system_bootstrapped": True, + } + ) + ) + + fail_agent = _FakeAgent(sdk, fail_first_send=True) + sdk._agents["agent-stale"] = fail_agent + + async def _resume(agent_id, options): + sdk.resumed.append((agent_id, options)) + agent = sdk._agents[agent_id] + agent.agent_id = agent_id + return agent + + sdk.agents_api.resume = _resume + + async def _run(): + await client.start() + events = [] + async for ev in client.prompt_stream("tick 1 data"): + events.append(ev) + await client.stop() + return events + + events = asyncio.run(_run()) + assert any(isinstance(e, TextChunk) and "FLAT after recovery" in e.text for e in events) + assert events[-1].stop_reason == "end_turn" + state = json.loads((agent_dir / "state" / "cursor_agent.json").read_text()) + assert state.get("agent_id", "").startswith("agent-test-") + assert "agent-stale" not in state.get("agent_id", "") diff --git a/tests/test_cursor_bridge_rotation.py b/tests/test_cursor_bridge_rotation.py new file mode 100644 index 00000000..26334d08 --- /dev/null +++ b/tests/test_cursor_bridge_rotation.py @@ -0,0 +1,83 @@ +"""Tests for Cursor SDK bridge error streak handling in TickEngine.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from condor.trading_agent.engine import ( + ROTATE_AFTER_CONSECUTIVE_CURSOR_ERRORS, + TickEngine, +) +from condor.trading_agent.strategy import Strategy + + +def _make_engine(tmp_path: Path, monkeypatch) -> TickEngine: + import condor.trading_agent.strategy as strategy_mod + + monkeypatch.setattr(strategy_mod, "_DATA_ROOT", tmp_path) + strategy = Strategy( + id="composer123456", + name="Composer Rotation Test Agent", + description="test", + agent_key="cursor-managed", + instructions="Trade BTC.", + ) + return TickEngine( + strategy=strategy, + config={ + "agent_key": "cursor-managed", + "execution_mode": "loop", + "risk_limits": {}, + "frequency_sec": 300, + }, + chat_id=0, + user_id=0, + ) + + +def test_start_rotates_cursor_agent_for_new_session(tmp_path, monkeypatch): + monkeypatch.setenv("CURSOR_API_KEY", "test-key") + engine = _make_engine(tmp_path, monkeypatch) + state_dir = engine.strategy.agent_dir / "state" + state_dir.mkdir(parents=True, exist_ok=True) + path = state_dir / "cursor_agent.json" + path.write_text(json.dumps({"agent_id": "agent-old", "agent_fingerprint": "abc"})) + + async def _run(): + await engine.start() + await engine.stop() + + asyncio.run(_run()) + state = json.loads(path.read_text()) + assert "agent_id" not in state + + +def test_cursor_error_streak_rotates_after_threshold(tmp_path, monkeypatch): + engine = _make_engine(tmp_path, monkeypatch) + state_dir = engine.strategy.agent_dir / "state" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "cursor_agent.json").write_text( + json.dumps({"agent_id": "agent-wedged", "agent_fingerprint": "fp"}) + ) + + engine._consecutive_cursor_errors = ROTATE_AFTER_CONSECUTIVE_CURSOR_ERRORS - 1 + notified: list[str] = [] + + async def _notify(msg: str) -> None: + notified.append(msg) + + monkeypatch.setattr(engine, "_notify", _notify) + + async def _run(): + await engine._handle_cursor_error_streak( + Exception("internal: internal error") + ) + + asyncio.run(_run()) + + assert engine._consecutive_cursor_errors == 0 + assert notified + state = json.loads((state_dir / "cursor_agent.json").read_text()) + assert "agent_id" not in state diff --git a/tests/test_cursor_engine_routing.py b/tests/test_cursor_engine_routing.py new file mode 100644 index 00000000..20a24eda --- /dev/null +++ b/tests/test_cursor_engine_routing.py @@ -0,0 +1,86 @@ +"""Tests for routing agent_key 'cursor-managed' through TickEngine and prompts.""" + +from __future__ import annotations + +import asyncio +import sys +import types + +from condor.acp.cursor_agent_client import CursorAgentClient, is_cursor_agent_key +from condor.trading_agent.engine import TickEngine +from condor.trading_agent.prompts import build_cursor_system_prompt, build_managed_tick_prompt +from condor.trading_agent.strategy import Strategy + + +def _install_fake_handlers(monkeypatch): + shared = types.ModuleType("handlers.agents._shared") + shared.build_mcp_servers_for_agent = lambda *a, **k: [] + shared.build_mcp_servers_for_session = lambda *a, **k: [] + shared.get_project_dir = lambda: "." + shared.is_dangerous_tool_call = lambda *a, **k: False + handlers_pkg = types.ModuleType("handlers") + agents_pkg = types.ModuleType("handlers.agents") + handlers_pkg.agents = agents_pkg + agents_pkg._shared = shared + monkeypatch.setitem(sys.modules, "handlers", handlers_pkg) + monkeypatch.setitem(sys.modules, "handlers.agents", agents_pkg) + monkeypatch.setitem(sys.modules, "handlers.agents._shared", shared) + + +def _make_strategy() -> Strategy: + return Strategy( + id="test12345678", + name="Composer Routing Test Agent Nonexistent", + description="test", + agent_key="cursor-managed", + instructions="Trade BTC perps. Avoid chop.", + ) + + +def _make_engine(config: dict) -> TickEngine: + base = {"execution_mode": "dry_run", "risk_limits": {}} + base.update(config) + return TickEngine(strategy=_make_strategy(), config=base, chat_id=0, user_id=0) + + +def test_is_cursor_agent_key(): + assert is_cursor_agent_key("cursor-managed") + assert is_cursor_agent_key("cursor-managed:composer-2.5") + assert not is_cursor_agent_key("claude-managed") + assert not is_cursor_agent_key("") + + +def test_create_client_routes_cursor_managed(monkeypatch): + _install_fake_handlers(monkeypatch) + engine = _make_engine({"agent_key": "cursor-managed", "model": "composer-2.5"}) + client = asyncio.run(engine._create_client()) + assert isinstance(client, CursorAgentClient) + assert client.model == "composer-2.5" + assert client.slug == "composer_routing_test_agent_nonexistent" + assert client.persist_session is False + assert "Avoid chop." in client.system_prompt + assert "/mnt/memory" not in client.system_prompt + assert "memory/" in client.system_prompt + + +def test_create_client_loop_mode_persists_session(monkeypatch): + _install_fake_handlers(monkeypatch) + engine = _make_engine({"agent_key": "cursor-managed", "execution_mode": "loop"}) + client = asyncio.run(engine._create_client()) + assert isinstance(client, CursorAgentClient) + assert client.persist_session is True + + +def test_create_client_inline_model_key(monkeypatch): + _install_fake_handlers(monkeypatch) + engine = _make_engine({"agent_key": "cursor-managed:composer-2.5"}) + client = asyncio.run(engine._create_client()) + assert client.model == "composer-2.5" + + +def test_cursor_system_prompt_uses_filesystem_memory(): + strategy = _make_strategy() + system = build_cursor_system_prompt(strategy, {"execution_mode": "dry_run"}, "") + assert "memory/" in system + assert "/mnt/memory" not in system + assert "Trade BTC perps. Avoid chop." in system diff --git a/tests/test_managed_tools.py b/tests/test_managed_tools.py new file mode 100644 index 00000000..a9ea7820 --- /dev/null +++ b/tests/test_managed_tools.py @@ -0,0 +1,106 @@ +"""Tests for the MCP-to-custom-tool bridge used by ManagedAgentClient.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from condor.acp.managed_tools import McpToolBridge + + +def _tool(name="manage_routines", description="Run routines.", schema=None): + return SimpleNamespace( + name=name, + description=description, + inputSchema=schema or {"type": "object", "properties": {"action": {"type": "string"}}}, + ) + + +class _FakeSession: + def __init__(self, result_text="ok", is_error=False): + self.calls: list[tuple[str, dict]] = [] + self._result_text = result_text + self._is_error = is_error + + async def call_tool(self, name, arguments): + self.calls.append((name, arguments)) + return SimpleNamespace( + content=[SimpleNamespace(type="text", text=self._result_text)], + isError=self._is_error, + ) + + +def test_register_builds_custom_tool_defs(): + bridge = McpToolBridge([]) + bridge._register("condor", _FakeSession(), [_tool()]) + defs = bridge.custom_tool_defs + assert len(defs) == 1 + d = defs[0] + assert d["type"] == "custom" + assert d["name"] == "manage_routines" + assert d["description"] == "Run routines." + assert d["input_schema"]["type"] == "object" + + +def test_register_truncates_long_descriptions_to_api_limit(): + # The Managed Agents API rejects custom tool descriptions > 1024 chars + bridge = McpToolBridge([]) + bridge._register("condor", _FakeSession(), [_tool(description="x" * 5000)]) + desc = bridge.custom_tool_defs[0]["description"] + assert len(desc) <= 1024 + + +def test_register_handles_missing_description_and_schema(): + bridge = McpToolBridge([]) + bridge._register("condor", _FakeSession(), [SimpleNamespace(name="t1", description=None, inputSchema=None)]) + d = bridge.custom_tool_defs[0] + assert d["description"] == "" + assert d["input_schema"] == {"type": "object", "properties": {}} + + +def test_register_first_server_wins_on_name_collision(): + bridge = McpToolBridge([]) + s1, s2 = _FakeSession("from-one"), _FakeSession("from-two") + bridge._register("one", s1, [_tool("dupe")]) + bridge._register("two", s2, [_tool("dupe")]) + assert len(bridge.custom_tool_defs) == 1 + text, is_error = asyncio.run(bridge.call("dupe", {})) + assert text == "from-one" + assert not is_error + + +def test_call_dispatches_to_owning_session(): + bridge = McpToolBridge([]) + session = _FakeSession("result-text") + bridge._register("condor", session, [_tool("manage_routines")]) + text, is_error = asyncio.run(bridge.call("manage_routines", {"action": "list"})) + assert text == "result-text" + assert not is_error + assert session.calls == [("manage_routines", {"action": "list"})] + + +def test_call_unknown_tool_returns_error(): + bridge = McpToolBridge([]) + text, is_error = asyncio.run(bridge.call("nope", {})) + assert is_error + assert "nope" in text + + +def test_call_propagates_tool_error_flag(): + bridge = McpToolBridge([]) + bridge._register("condor", _FakeSession("boom", is_error=True), [_tool("t")]) + text, is_error = asyncio.run(bridge.call("t", {})) + assert is_error + assert text == "boom" + + +def test_call_survives_session_exception(): + class _ExplodingSession: + async def call_tool(self, name, arguments): + raise RuntimeError("pipe broken") + + bridge = McpToolBridge([]) + bridge._register("condor", _ExplodingSession(), [_tool("t")]) + text, is_error = asyncio.run(bridge.call("t", {})) + assert is_error + assert "pipe broken" in text diff --git a/tests/test_session_rotation.py b/tests/test_session_rotation.py new file mode 100644 index 00000000..d875ba68 --- /dev/null +++ b/tests/test_session_rotation.py @@ -0,0 +1,181 @@ +"""Tests for managed-session rotation after consecutive tick timeouts. + +Bug history: session 2 wedged on 2026-06-11 22:16 UTC — the hosted session +stopped echoing user messages and every tick for 15 hours (155 ticks) timed +out silently. The engine kept reusing the wedged session_id from +managed_agent.json. Fix: after N consecutive timeouts, drop the persisted +session (fresh one is provisioned next tick; agent + memory store are kept) +and alert the user. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import types +from pathlib import Path + +import pytest + +from condor.acp.managed_agent_client import ManagedAgentClient +from condor.trading_agent.engine import ROTATE_AFTER_CONSECUTIVE_TIMEOUTS, TickEngine +from condor.trading_agent.strategy import Strategy + + +# ── rotate_persisted_session helper ── + + +class _FakeSessions: + def __init__(self, fail_delete: bool = False): + self.deleted: list[str] = [] + self.fail_delete = fail_delete + + async def delete(self, session_id: str): + if self.fail_delete: + raise RuntimeError("api down") + self.deleted.append(session_id) + + +class _FakeSDK: + def __init__(self, fail_delete: bool = False): + self.beta = types.SimpleNamespace(sessions=_FakeSessions(fail_delete)) + + +def _write_state(agent_dir: Path, session_id: str = "sesn_wedged") -> Path: + state_dir = agent_dir / "state" + state_dir.mkdir(parents=True, exist_ok=True) + path = state_dir / "managed_agent.json" + path.write_text(json.dumps({ + "agent_id": "agent_x", + "agent_fingerprint": "abc123", + "memory_store_id": "memstore_y", + "session_id": session_id, + })) + return path + + +def test_rotate_drops_session_and_keeps_agent_and_memory(tmp_path): + path = _write_state(tmp_path) + sdk = _FakeSDK() + dropped = asyncio.run( + ManagedAgentClient.rotate_persisted_session(tmp_path, sdk_client=sdk) + ) + assert dropped == "sesn_wedged" + state = json.loads(path.read_text()) + assert "session_id" not in state or not state["session_id"] + assert state["agent_id"] == "agent_x" + assert state["memory_store_id"] == "memstore_y" + assert sdk.beta.sessions.deleted == ["sesn_wedged"] + + +def test_rotate_tolerates_delete_failure(tmp_path): + path = _write_state(tmp_path) + dropped = asyncio.run( + ManagedAgentClient.rotate_persisted_session( + tmp_path, sdk_client=_FakeSDK(fail_delete=True) + ) + ) + assert dropped == "sesn_wedged" # local state still cleared + assert "sesn_wedged" not in path.read_text() + + +def test_rotate_no_state_file_is_noop(tmp_path): + dropped = asyncio.run( + ManagedAgentClient.rotate_persisted_session(tmp_path, sdk_client=_FakeSDK()) + ) + assert dropped == "" + + +def test_rotate_no_session_in_state_is_noop(tmp_path): + state_dir = tmp_path / "state" + state_dir.mkdir(parents=True) + (state_dir / "managed_agent.json").write_text(json.dumps({"agent_id": "agent_x"})) + dropped = asyncio.run( + ManagedAgentClient.rotate_persisted_session(tmp_path, sdk_client=_FakeSDK()) + ) + assert dropped == "" + + +# ── engine timeout-streak handling ── + + +def _make_engine(tmp_path, monkeypatch) -> TickEngine: + import condor.trading_agent.strategy as strategy_mod + + monkeypatch.setattr(strategy_mod, "_DATA_ROOT", tmp_path) + strategy = Strategy( + id="test12345678", + name="Rotation Test Agent Nonexistent", + description="test", + agent_key="claude-managed", + instructions="Trade.", + ) + engine = TickEngine( + strategy=strategy, + config={"agent_key": "claude-managed", "execution_mode": "loop", "risk_limits": {}}, + chat_id=0, + user_id=0, + ) + return engine + + +def test_streak_below_threshold_does_not_rotate(tmp_path, monkeypatch): + engine = _make_engine(tmp_path, monkeypatch) + _write_state(engine.strategy.agent_dir) + notifications: list[str] = [] + + async def fake_notify(msg): + notifications.append(msg) + + monkeypatch.setattr(engine, "_notify", fake_notify) + + for _ in range(ROTATE_AFTER_CONSECUTIVE_TIMEOUTS - 1): + asyncio.run(engine._handle_timeout_streak(timed_out=True)) + + state = json.loads( + (engine.strategy.agent_dir / "state" / "managed_agent.json").read_text() + ) + assert state["session_id"] == "sesn_wedged" + assert notifications == [] + + +def test_streak_at_threshold_rotates_and_notifies(tmp_path, monkeypatch): + engine = _make_engine(tmp_path, monkeypatch) + state_path = _write_state(engine.strategy.agent_dir) + notifications: list[str] = [] + + async def fake_notify(msg): + notifications.append(msg) + + monkeypatch.setattr(engine, "_notify", fake_notify) + # Avoid creating a real Anthropic client for the server-side delete + fake_sdk = _FakeSDK() + original = ManagedAgentClient.rotate_persisted_session + + async def fake_rotate(agent_dir, sdk_client=None): + return await original(agent_dir, sdk_client=fake_sdk) + + monkeypatch.setattr( + ManagedAgentClient, "rotate_persisted_session", staticmethod(fake_rotate) + ) + + for _ in range(ROTATE_AFTER_CONSECUTIVE_TIMEOUTS): + asyncio.run(engine._handle_timeout_streak(timed_out=True)) + + state = json.loads(state_path.read_text()) + assert not state.get("session_id") + assert len(notifications) == 1 + assert "timeout" in notifications[0].lower() + # Counter resets after rotation so the next streak re-alerts + assert engine._consecutive_timeouts == 0 + + +def test_successful_tick_resets_streak(tmp_path, monkeypatch): + engine = _make_engine(tmp_path, monkeypatch) + _write_state(engine.strategy.agent_dir) + + asyncio.run(engine._handle_timeout_streak(timed_out=True)) + asyncio.run(engine._handle_timeout_streak(timed_out=True)) + asyncio.run(engine._handle_timeout_streak(timed_out=False)) + assert engine._consecutive_timeouts == 0 diff --git a/uv.lock b/uv.lock index 110f1ad6..175eb806 100644 --- a/uv.lock +++ b/uv.lock @@ -164,7 +164,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.93.0" +version = "0.115.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -176,9 +176,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/70/2429d6f7c2516db99fb342c3ad89575ab3e0cd31d3d2f6cba5fdf5e9c65b/anthropic-0.93.0.tar.gz", hash = "sha256:fea8376f7d5cdf99d5e8e85a48fe7a7bd8ab307cdfee4b1e8283a18b1c0ce1b5", size = 654155, upload-time = "2026-04-09T18:13:53.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e2/e27b1e70b1ddcda72362d6a26c9c699a46173d48a6c7ba966e8cc97a6c4d/anthropic-0.115.1.tar.gz", hash = "sha256:040287319abb909acf1cc49d83c0405dc0b1121ef257034b02c2b54151cf2446", size = 949185, upload-time = "2026-07-01T21:54:19.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/7b/5b2c11902707c49c7a99418eb027ed3eb63876193fee5c80b5c878e3a673/anthropic-0.93.0-py3-none-any.whl", hash = "sha256:2c20b2ce6d305564c66a6cbaedddee8efdd3b9753098bf314093fcf4c662d04c", size = 627482, upload-time = "2026-04-09T18:13:51.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/3c/501c58a8f8c68811079e218a25d8672fe4fb9a650a4655e499e24d9375e9/anthropic-0.115.1-py3-none-any.whl", hash = "sha256:685fa94964c1b9428f6a76e42d0dbae49aa2016f5ad386a96becac04c5e80ef9", size = 957006, upload-time = "2026-07-01T21:54:17.392Z" }, ] [[package]] @@ -588,7 +588,9 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, + { name = "anthropic" }, { name = "beautifulsoup4" }, + { name = "cursor-sdk" }, { name = "fastapi" }, { name = "faster-whisper" }, { name = "geckoterminal-py" }, @@ -620,8 +622,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp" }, + { name = "anthropic", specifier = ">=0.109.1" }, { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "black", marker = "extra == 'dev'" }, + { name = "cursor-sdk", specifier = ">=0.1.8" }, { name = "fastapi" }, { name = "faster-whisper" }, { name = "geckoterminal-py" }, @@ -797,6 +801,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" }, ] +[[package]] +name = "cursor-sdk" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/23/22aa3ef2f4eef082b145fe1d151e28f23f0d93f0f33051106584d1812e67/cursor_sdk-0.1.8.tar.gz", hash = "sha256:4e3ab986f3cbf9d98e7013a8a30299c077ff423b58c772a40be10222e1b3b898", size = 1175, upload-time = "2026-06-19T17:48:44.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/9f/6599a3c6f115a6e4de990e892f6b74827d4151f2ebaffd44246dacabf048/cursor_sdk-0.1.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2cf8b54690421076fc9664f86f76fbb6b0403445a1ca540181129f32bc433e5", size = 47899658, upload-time = "2026-06-19T17:48:30.028Z" }, + { url = "https://files.pythonhosted.org/packages/63/ea/b54c7b9d28fa0c348c161fdfbfb60a52fc07f3032827672e2c575f73e922/cursor_sdk-0.1.8-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cbde715958d79f3c71c1ee107819d9f65bd6c8134f1f10b1b7ce4cbdb87486f6", size = 49396854, upload-time = "2026-06-19T17:48:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/78/d5/01629a189d388c2858fed1381207c7b9adeedcafc59889aba230742de677/cursor_sdk-0.1.8-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d2b95148c19b52a5f30ba5f0632f2b0ed94fc9cd7c4f9e86472eb90939d2b3e", size = 56466607, upload-time = "2026-06-19T17:48:35.925Z" }, + { url = "https://files.pythonhosted.org/packages/e3/03/3c3d336d4397b094630a43482e18a22ab66ffb142fb29c6392af0c060844/cursor_sdk-0.1.8-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df9d91aa17418f8a6f9dbdfd074e00b867c2491addb281c2dd3720cfce4d972a", size = 57115365, upload-time = "2026-06-19T17:48:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/5d/18/eaaeba4558653e3d743b5736c726e749d935ba5c97b68c4471194427f0d2/cursor_sdk-0.1.8-py3-none-win_amd64.whl", hash = "sha256:b33be04b2106ebc5697413d33ddca22a317a7efc79227a8eb09065fea1e0f031", size = 43888685, upload-time = "2026-06-19T17:48:41.95Z" }, +] + [[package]] name = "cycler" version = "0.12.1"