diff --git a/condor/acp/__init__.py b/condor/acp/__init__.py index 2903095a..fbd6b12d 100644 --- a/condor/acp/__init__.py +++ b/condor/acp/__init__.py @@ -10,4 +10,4 @@ Heartbeat, ACPEvent, ) -from .pydantic_ai_client import PydanticAIClient, is_pydantic_ai_model +from .openai_compatible import OpenAICompatibleClient, is_openai_compatible_agent diff --git a/condor/acp/openai_compatible.py b/condor/acp/openai_compatible.py new file mode 100644 index 00000000..5ff6cbdc --- /dev/null +++ b/condor/acp/openai_compatible.py @@ -0,0 +1,233 @@ +"""OpenAI-compatible chat client (LM Studio, local OpenAI API, etc.). + +Implements the same surface as :class:`ACPClient` (``start``, ``stop``, ``alive``, +``prompt``, ``prompt_stream``) so handlers can swap backends. Tool calls and MCP +are not wired through this client — use Claude or Gemini for full MCP support. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any, AsyncIterator + +import aiohttp + +from .client import ( + ACPEvent, + Heartbeat, + PromptDone, + PermissionCallback, + TextChunk, + ThoughtChunk, +) + +log = logging.getLogger(__name__) + +OPENAI_COMPATIBLE_AGENT_KEYS = frozenset({"lm-studio"}) + + +def _env(name: str, default: str) -> str: + v = os.environ.get(name) + return v.strip() if v else default + + +def lm_studio_base_url() -> str: + """Base URL including ``/v1`` (e.g. ``http://127.0.0.1:1234/v1``).""" + return _env("LM_STUDIO_BASE_URL", "http://127.0.0.1:1234/v1").rstrip("/") + + +def lm_studio_api_key() -> str: + return _env("LM_STUDIO_API_KEY", "lm-studio") + + +def lm_studio_model() -> str | None: + m = os.environ.get("LM_STUDIO_MODEL") + return m.strip() if m and m.strip() else None + + +def lm_studio_temperature() -> float: + try: + return float(os.environ.get("LM_STUDIO_TEMPERATURE", "0.7")) + except ValueError: + return 0.7 + + +async def _fetch_default_model(base: str, api_key: str) -> str | None: + """GET /v1/models and return the first model id.""" + url = f"{base}/models" + headers = {"Authorization": f"Bearer {api_key}"} + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + log.warning("LM Studio models list HTTP %s", resp.status) + return None + data = await resp.json() + except Exception: + log.exception("Failed to list LM Studio models") + return None + models = data.get("data") or [] + if not models: + return None + first = models[0] + mid = first.get("id") if isinstance(first, dict) else None + return mid + + +class OpenAICompatibleClient: + """Chat Completions API with streaming; conversation state held in memory.""" + + def __init__( + self, + *, + base_url: str | None = None, + model: str | None = None, + api_key: str | None = None, + temperature: float | None = None, + working_dir: str | None = None, + permission_callback: PermissionCallback | None = None, + mcp_servers: list[dict[str, Any]] | None = None, + extra_env: dict[str, str] | None = None, + ): + self._base = (base_url or lm_studio_base_url()).rstrip("/") + self._model = model if model is not None else lm_studio_model() + self._api_key = api_key if api_key is not None else lm_studio_api_key() + self._temperature = temperature if temperature is not None else lm_studio_temperature() + self.working_dir = working_dir # unused; parity with ACPClient + self.permission_callback = permission_callback + self.mcp_servers = mcp_servers or [] + self.extra_env = extra_env or {} + self._messages: list[dict[str, Any]] = [] + self._started = False + self._http: aiohttp.ClientSession | None = None + + @property + def command(self) -> str: + return "openai-compatible" + + async def start(self) -> None: + if self._started: + return + timeout = aiohttp.ClientTimeout(total=None, sock_connect=10, sock_read=300) + self._http = aiohttp.ClientSession(timeout=timeout) + if not self._model: + self._model = await _fetch_default_model(self._base, self._api_key) + if not self._model: + await self.stop() + raise RuntimeError( + "No LM Studio model configured. Set LM_STUDIO_MODEL or load a model in LM Studio, " + f"then ensure the server is running at {self._base}." + ) + self._started = True + log.info("OpenAI-compatible session started (model=%s, base=%s)", self._model, self._base) + + async def stop(self) -> None: + self._started = False + if self._http: + await self._http.close() + self._http = None + + @property + def alive(self) -> bool: + return self._started and self._http is not None and not self._http.closed + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + 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._http is not None and self._model + + self._messages.append({"role": "user", "content": text}) + url = f"{self._base}/chat/completions" + body: dict[str, Any] = { + "model": self._model, + "messages": self._messages, + "stream": True, + "temperature": self._temperature, + } + + loop = asyncio.get_event_loop() + start_time = loop.time() + max_duration = 1860.0 + + try: + async with self._http.post(url, headers=self._headers(), json=body) as resp: + if resp.status != 200: + err_text = await resp.text() + log.error("OpenAI-compatible chat/completions HTTP %s: %s", resp.status, err_text[:500]) + yield PromptDone(stop_reason="error") + return + + assistant_parts: list[str] = [] + raw_buf = b"" + done_seen = False + + while not done_seen: + try: + chunk = await asyncio.wait_for(resp.content.read(65536), timeout=30.0) + except asyncio.TimeoutError: + elapsed = loop.time() - start_time + if not self.alive: + yield PromptDone(stop_reason="disconnected") + return + if elapsed > max_duration: + yield PromptDone(stop_reason="timeout") + return + yield Heartbeat(elapsed_seconds=elapsed) + continue + + if not chunk: + break + raw_buf += chunk + while b"\n" in raw_buf: + line, raw_buf = raw_buf.split(b"\n", 1) + line = line.decode(errors="replace").strip() + if not line or line.startswith(":"): + continue + if line == "data: [DONE]": + done_seen = True + break + if not line.startswith("data:"): + continue + payload = line[5:].strip() + try: + data = json.loads(payload) + except json.JSONDecodeError: + continue + for choice in data.get("choices") or []: + delta = choice.get("delta") or {} + piece = delta.get("content") + if piece: + assistant_parts.append(piece) + yield TextChunk(text=piece) + for key in ("reasoning_content", "reasoning"): + r = delta.get(key) + if isinstance(r, str) and r: + yield ThoughtChunk(text=r) + if done_seen: + break + + text_full = "".join(assistant_parts) + if text_full: + self._messages.append({"role": "assistant", "content": text_full}) + yield PromptDone(stop_reason="end_turn") + except aiohttp.ClientError as e: + log.exception("OpenAI-compatible request failed: %s", e) + yield PromptDone(stop_reason="error") + + +def is_openai_compatible_agent(agent_key: str) -> bool: + return agent_key in OPENAI_COMPATIBLE_AGENT_KEYS diff --git a/condor/trading_agent/engine.py b/condor/trading_agent/engine.py index 0a837559..724dc259 100644 --- a/condor/trading_agent/engine.py +++ b/condor/trading_agent/engine.py @@ -25,7 +25,7 @@ ToolCallEvent, ToolCallUpdate, ) -from condor.acp.pydantic_ai_client import PydanticAIClient, is_pydantic_ai_model +from condor.acp.openai_compatible import OpenAICompatibleClient, is_openai_compatible_agent from .journal import JournalManager, next_experiment_number, next_session_number from .prompts import build_tick_prompt @@ -282,7 +282,7 @@ async def _tick(self) -> None: ) self._pending_directives.clear() - # 6. Create agent client (ACP for Claude/Gemini, PydanticAI for open-source models) + # 6. Create ACP session from handlers.agents._shared import ( build_mcp_servers_for_agent, build_mcp_servers_for_session, @@ -305,20 +305,14 @@ async def _tick(self) -> None: ) permission_cb = auto_approve_with_risk_check(self.risk, risk_state, execution_mode=mode) - # Session config overrides strategy default for agent_key - agent_key = self.config.get("agent_key") or self.strategy.agent_key - use_pydantic_ai = is_pydantic_ai_model(agent_key) - - if use_pydantic_ai: - base_url = self.config.get("model_base_url") or None - acp_client = PydanticAIClient( - model=agent_key, + if is_openai_compatible_agent(self.strategy.agent_key): + acp_client = OpenAICompatibleClient( + working_dir=get_project_dir(), mcp_servers=mcp_servers, permission_callback=permission_cb, - base_url=base_url, ) else: - agent_cmd = ACP_COMMANDS.get(agent_key, ACP_COMMANDS["claude-code"]) + agent_cmd = ACP_COMMANDS.get(self.strategy.agent_key, ACP_COMMANDS["claude-code"]) acp_client = ACPClient( command=agent_cmd, working_dir=get_project_dir(), @@ -389,7 +383,6 @@ async def _tick(self) -> None: executors_data=executors_summary, risk_state=risk_state.to_dict(), duration=tick_duration, - agent_key=agent_key, ) log.info( "TickEngine %s experiment #%d complete (tools=%d, response=%d chars)", @@ -437,7 +430,11 @@ async def _tick(self) -> None: self.agent_id, tick_num, len(tool_calls), len(response_text), ) - async def _collect_stream(self, acp_client: ACPClient, prompt: str): + async def _collect_stream( + self, + acp_client: ACPClient | OpenAICompatibleClient, + prompt: str, + ): """Wrapper to make prompt_stream compatible with wait_for.""" async for event in acp_client.prompt_stream(prompt): yield event @@ -517,7 +514,6 @@ def get_info(self) -> dict[str, Any]: "total_amount_quote": self.config.get("total_amount_quote", 100), "trading_context": self.config.get("trading_context", ""), "risk_limits": risk_limits if isinstance(risk_limits, dict) else risk_limits.model_dump() if hasattr(risk_limits, "model_dump") else {}, - "agent_key": self.config.get("agent_key") or self.strategy.agent_key, "execution_mode": self.config.get("execution_mode", "loop"), "max_ticks": self.config.get("max_ticks", 0), "last_tick_at": self._last_tick_at, diff --git a/condor/trading_agent/strategy.py b/condor/trading_agent/strategy.py index fac9b409..18b54d7e 100644 --- a/condor/trading_agent/strategy.py +++ b/condor/trading_agent/strategy.py @@ -97,7 +97,7 @@ class Strategy: id: str name: str description: str - agent_key: str # "claude-code", "gemini", or pydantic-ai model like "ollama:llama3.1" + agent_key: str # "claude-code", "gemini", or "lm-studio" instructions: str # The strategy logic text for the LLM skills: list[str] = field(default_factory=list) # Optional skill names default_config: dict[str, Any] = field(default_factory=dict) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49dbf054..d0b10fdc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -273,21 +273,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -296,9 +296,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -566,26 +566,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -593,9 +595,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -610,9 +612,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -627,9 +629,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -644,9 +646,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -661,9 +663,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -678,9 +680,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], @@ -695,9 +697,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], @@ -712,9 +714,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], @@ -729,9 +731,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], @@ -746,9 +748,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], @@ -763,9 +765,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], @@ -780,9 +782,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -797,9 +799,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -807,16 +809,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -831,9 +835,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -1406,9 +1410,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1612,9 +1616,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2982,14 +2986,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2998,27 +3002,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -3262,16 +3266,16 @@ } }, "node_modules/vite": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", - "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.11", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -3289,7 +3293,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/handlers/agents/__init__.py b/handlers/agents/__init__.py index a7d48021..26f1a505 100644 --- a/handlers/agents/__init__.py +++ b/handlers/agents/__init__.py @@ -22,8 +22,7 @@ ) from .confirmation import resolve_confirmation from .menu import show_agent_menu -from condor.acp import ACP_COMMANDS, PromptDone -from condor.acp.pydantic_ai_client import is_pydantic_ai_model +from condor.acp import ACP_COMMANDS, PromptDone, is_openai_compatible_agent from .session import destroy_session, get_or_create_session, get_session from .stream import TelegramStreamer @@ -33,20 +32,40 @@ _cli_available_cache: dict[str, bool] = {} -def _is_agent_available(agent_key: str) -> bool: - """Check if the agent backend is available. +def _lm_studio_reachable() -> bool: + """Return True if LM Studio (or compatible) responds on the configured OpenAI base URL.""" + import os + import urllib.error + import urllib.request - For ACP agents (claude-code, gemini): checks if the CLI binary is in PATH. - For pydantic-ai agents (ollama:*, openai:*, etc.): always available - (pydantic-ai handles connection errors at runtime). - """ - # Pydantic-ai models don't need a CLI binary - if is_pydantic_ai_model(agent_key): + from condor.acp.openai_compatible import lm_studio_api_key, lm_studio_base_url + + if os.environ.get("LM_STUDIO_SKIP_VERIFY", "").lower() in ("1", "true", "yes"): return True + base = lm_studio_base_url() + key = lm_studio_api_key() + url = f"{base}/models" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"}) + try: + with urllib.request.urlopen(req, timeout=2) as r: + return r.status == 200 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def _is_agent_available(agent_key: str) -> bool: + """Check if the CLI binary for the given agent is installed.""" if agent_key in _cli_available_cache: return _cli_available_cache[agent_key] + if is_openai_compatible_agent(agent_key): + ok = _lm_studio_reachable() + _cli_available_cache[agent_key] = ok + if not ok: + log.warning("LM Studio API not reachable (check LM_STUDIO_BASE_URL and server)") + return ok + cmd = ACP_COMMANDS.get(agent_key, ACP_COMMANDS.get("claude-code", "")) # The command may have flags (e.g. "gemini --experimental-acp"), check the binary binary = cmd.split()[0] if cmd else "" @@ -81,10 +100,12 @@ async def agent_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N available = [k for k in AGENT_OPTIONS if _is_agent_available(k)] if not available: await update.message.reply_text( - "No agent CLI found.\n\n" + "No agent backend found.\n\n" "Install one of:\n" "• claude-agent-acp (Claude Agent)\n" - "• gemini (Gemini CLI)\n\n" + "• gemini (Gemini CLI)\n" + "• LM Studio with the local server running (OpenAI API), or set " + "LM_STUDIO_SKIP_VERIFY=1 if the health check fails.\n\n" "Then restart the bot." ) return @@ -204,7 +225,9 @@ async def _perm_cb(tool_call, options): # Inject mode-specific context extra_context = None if mode == "agent_builder": - extra_context = build_trading_context() + extra_context = build_trading_context( + supports_mcp=not is_openai_compatible_agent(agent_key), + ) if extra_context: try: @@ -627,7 +650,9 @@ async def _perm_cb(tool_call, options): # Inject mode-specific context for non-condor modes extra_context = None if mode == "agent_builder": - extra_context = build_trading_context() + extra_context = build_trading_context( + supports_mcp=not is_openai_compatible_agent(agent_key), + ) if extra_context: try: diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index 27eee733..ee256e0e 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -9,13 +9,7 @@ AGENT_OPTIONS: dict[str, dict[str, str]] = { "claude-code": {"label": "Claude Code"}, "gemini": {"label": "Gemini CLI"}, - "ollama:llama3.1": {"label": "Ollama — Llama 3.1"}, - "ollama:qwen3:32b": {"label": "Ollama — Qwen 3 32B"}, - "ollama:qwen2.5:72b": {"label": "Ollama — Qwen 2.5 72B"}, - "ollama:deepseek-r1:32b": {"label": "Ollama — DeepSeek R1 32B"}, - "lmstudio:default": {"label": "LM Studio — Default Model"}, - "openai:gpt-4o": {"label": "OpenAI — GPT-4o"}, - "groq:llama-3.3-70b-versatile": {"label": "Groq — Llama 3.3 70B"}, + "lm-studio": {"label": "LM Studio (local API)"}, } DEFAULT_AGENT = "claude-code" @@ -86,8 +80,6 @@ PHASE 4 — DRY RUN - Start with execution_mode: "dry_run" to validate without live trading. -- The user can choose which model to dry-run with by passing agent_key in config \ -(e.g. config={"execution_mode": "dry_run", "agent_key": "ollama:llama3.1"}). - Review journal output with the user. - Check: Does the agent call routines correctly? Is decision logic sound? \ Does it use conditional language? Are risk rules respected? @@ -97,8 +89,6 @@ PHASE 5 — GO LIVE - Offer execution modes: run_once (single tick), loop (continuous), \ or loop with max_ticks (limited run). -- Ask which model to use for live trading — the user can pick a different model \ -than the one used in dry-run (e.g. dry-run with ollama, go live with claude-code). - Start the agent with the user's chosen mode and config. - Confirm the agent is running and provide monitoring commands. @@ -116,22 +106,6 @@ REFERENCE ═══════════════════════════════════════════════════════════ -MODEL SELECTION: -The model (agent_key) is set per SESSION, not per strategy. The strategy's \ -agent_key is just the default. Override it at launch via config: - manage_trading_agent(action="start_agent", strategy_id=..., \ -config={"agent_key": "ollama:qwen3:32b", "execution_mode": "dry_run"}) - -Available models: -- ACP (subprocess CLI): "claude-code", "gemini" -- Pydantic AI (local): "ollama:llama3.1", "ollama:qwen3:32b", \ -"ollama:qwen2.5:72b", "ollama:deepseek-r1:32b", "lmstudio:" -- Pydantic AI (cloud): "openai:gpt-4o", "groq:llama-3.3-70b-versatile" -- Custom endpoint: use "openai:" + model_base_url in config - -Default URLs (no config needed): Ollama=localhost:11434, LM Studio=localhost:1234. \ -Override with model_base_url in config if running on a different host/port. - GENERIC vs SPECIFIC STRATEGIES: - GENERIC: trading_pair and connector are NOT in the instructions. Passed at \ launch via `trading_context`. Refer to "the configured trading pair". Default. @@ -182,12 +156,22 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: - Be interactive. Guide the user one step at a time. Offer concrete proposals. """ -def build_trading_context() -> str: +TRADING_SYSTEM_PROMPT_NO_MCP = """\ +[System context — do not repeat this to the user] +You are in TRADING AGENT / Agent Builder mode but WITHOUT MCP tool access (local LLM via LM Studio). +You cannot call manage_trading_agent, manage_routines, or any Hummingbot/Condor tools. +Help with strategy concepts, risk framing, and routine ideas as plain text or pseudocode. +When the user needs live execution, running agents, or the full builder workflow with tools, +tell them to switch to Claude Code or Gemini CLI in /agent settings. +""" + + +def build_trading_context(supports_mcp: bool = True) -> str: """Build the trading-focused initial context prompt.""" from condor.trading_agent.strategy import StrategyStore from condor.trading_agent.engine import get_all_engines - sections = [TRADING_SYSTEM_PROMPT] + sections = [TRADING_SYSTEM_PROMPT if supports_mcp else TRADING_SYSTEM_PROMPT_NO_MCP] # List existing strategies store = StrategyStore() @@ -453,15 +437,30 @@ def build_mcp_servers_for_agent( return [mcp_hummingbot, condor] -def build_initial_context(user_id: int, chat_id: int, user_data: dict | None = None) -> str: +def build_initial_context( + user_id: int, + chat_id: int, + user_data: dict | None = None, + supports_mcp: bool = True, +) -> str: """Build an initial context prompt telling the agent about server, permissions, and formatting rules.""" - from config_manager import ServerPermission, get_config_manager, get_effective_server + from config_manager import get_config_manager, get_effective_server cm = get_config_manager() # Always start with Telegram formatting rules sections: list[str] = [TELEGRAM_SYSTEM_PROMPT] + if not supports_mcp: + sections.append( + "[System context -- do not repeat this to the user]\n" + "This session uses a local LLM via the OpenAI-compatible API (e.g. LM Studio) " + "without MCP tools. You cannot call Hummingbot or Condor tools. " + "Answer from general knowledge and any server details below. " + "If the user needs live balances, orders, or other tool-backed data, " + "say they should switch to Claude Code or Gemini CLI in /agent settings." + ) + # Resolve active server (respects user preferences) active_name = get_effective_server(chat_id, user_data) accessible = cm.get_accessible_servers(user_id) @@ -484,73 +483,82 @@ def build_initial_context(user_id: int, chat_id: int, user_data: dict | None = N f"user={server['username']}, pass={server['password']}" ) - # Build configure_server call for the active server - active_server = cm.get_server(active_name) - if active_server: - configure_hint = ( - f'IMPORTANT: Before making ANY tool calls to mcp-hummingbot, you MUST first call ' - f'configure_server(host="{active_server["host"]}", port={active_server["port"]}, ' - f'username="{active_server["username"]}", password="{active_server["password"]}") ' - f'to ensure the MCP is connected to the correct server. ' - f'Do this silently without telling the user.' + if supports_mcp: + active_server = cm.get_server(active_name) + if active_server: + configure_hint = ( + f'IMPORTANT: Before making ANY tool calls to mcp-hummingbot, you MUST first call ' + f'configure_server(host="{active_server["host"]}", port={active_server["port"]}, ' + f'username="{active_server["username"]}", password="{active_server["password"]}") ' + f'to ensure the MCP is connected to the correct server. ' + f'Do this silently without telling the user.' + ) + else: + configure_hint = "" + + mcp_tools = [ + "mcp__mcp-hummingbot__configure_server", + "mcp__mcp-hummingbot__get_market_data", + "mcp__mcp-hummingbot__get_portfolio_overview", + "mcp__mcp-hummingbot__manage_executors", + "mcp__mcp-hummingbot__manage_bots", + "mcp__mcp-hummingbot__manage_controllers", + "mcp__mcp-hummingbot__explore_dex_pools", + "mcp__mcp-hummingbot__explore_geckoterminal", + "mcp__mcp-hummingbot__manage_gateway_swaps", + "mcp__mcp-hummingbot__manage_gateway_config", + "mcp__mcp-hummingbot__manage_gateway_container", + "mcp__mcp-hummingbot__search_history", + "mcp__mcp-hummingbot__setup_connector", + "mcp__mcp-hummingbot__set_account_position_mode_and_leverage", + "mcp__condor__manage_routines", + "mcp__condor__manage_servers", + "mcp__condor__get_user_context", + "mcp__condor__manage_trading_agent", + "mcp__condor__trading_agent_journal_read", + "mcp__condor__trading_agent_journal_write", + "mcp__condor__send_notification", + "mcp__condor__manage_notes", + "mcp__condor__manage_skills", + ] + tool_preload_hint = ( + "IMPORTANT: At the very start of the session (before your first response), " + "load ALL MCP tools in a single ToolSearch call:\n" + f'ToolSearch(query="select:{",".join(mcp_tools)}")\n' + "This avoids repeated ToolSearch calls that waste context tokens. " + "Do this silently without telling the user." ) + + sections.append("\n".join([ + f"Active server: {active_name}", + "", + tool_preload_hint, + "", + configure_hint, + "", + "Available servers:", + *server_lines, + "", + "To switch servers, use configure_server with the credentials above.", + 'Example: configure_server(host="localhost", port=8000, username="admin", password="admin")', + "Only use servers listed here.", + "", + "Permission rules:", + "- OWNER: Full access including trading operations and server management.", + "- TRADER: Can trade, view balances, and manage own settings.", + "", + "After switching servers, enforce the permission level shown for that server.", + ])) else: - configure_hint = "" - - # Instruct agent to preload all MCP tools in one shot to avoid - # repeated ToolSearch calls that bloat the context window. - mcp_tools = [ - "mcp__mcp-hummingbot__configure_server", - "mcp__mcp-hummingbot__get_market_data", - "mcp__mcp-hummingbot__get_portfolio_overview", - "mcp__mcp-hummingbot__manage_executors", - "mcp__mcp-hummingbot__manage_bots", - "mcp__mcp-hummingbot__manage_controllers", - "mcp__mcp-hummingbot__explore_dex_pools", - "mcp__mcp-hummingbot__explore_geckoterminal", - "mcp__mcp-hummingbot__manage_gateway_swaps", - "mcp__mcp-hummingbot__manage_gateway_config", - "mcp__mcp-hummingbot__manage_gateway_container", - "mcp__mcp-hummingbot__search_history", - "mcp__mcp-hummingbot__setup_connector", - "mcp__mcp-hummingbot__set_account_position_mode_and_leverage", - "mcp__condor__manage_routines", - "mcp__condor__manage_servers", - "mcp__condor__get_user_context", - "mcp__condor__manage_trading_agent", - "mcp__condor__trading_agent_journal_read", - "mcp__condor__trading_agent_journal_write", - "mcp__condor__send_notification", - "mcp__condor__manage_notes", - "mcp__condor__manage_skills", - ] - tool_preload_hint = ( - "IMPORTANT: At the very start of the session (before your first response), " - "load ALL MCP tools in a single ToolSearch call:\n" - f'ToolSearch(query="select:{",".join(mcp_tools)}")\n' - "This avoids repeated ToolSearch calls that waste context tokens. " - "Do this silently without telling the user." - ) - - sections.append("\n".join([ - f"Active server: {active_name}", - "", - tool_preload_hint, - "", - configure_hint, - "", - "Available servers:", - *server_lines, - "", - "To switch servers, use configure_server with the credentials above.", - 'Example: configure_server(host="localhost", port=8000, username="admin", password="admin")', - "Only use servers listed here.", - "", - "Permission rules:", - "- OWNER: Full access including trading operations and server management.", - "- TRADER: Can trade, view balances, and manage own settings.", - "", - "After switching servers, enforce the permission level shown for that server.", - ])) + sections.append("\n".join([ + f"Active server (reference only; you cannot call tools): {active_name}", + "", + "Available servers:", + *server_lines, + "", + "Permission rules (for your guidance only):", + "- OWNER: Full access including trading operations and server management.", + "- TRADER: Can trade, view balances, and manage own settings.", + ])) return "\n\n".join(sections) diff --git a/handlers/agents/session.py b/handlers/agents/session.py index 5215c4d3..d960fd5e 100644 --- a/handlers/agents/session.py +++ b/handlers/agents/session.py @@ -6,7 +6,14 @@ from telegram import Bot -from condor.acp import ACP_COMMANDS, ACPClient, PermissionCallback, PromptDone +from condor.acp import ( + ACP_COMMANDS, + ACPClient, + OpenAICompatibleClient, + PermissionCallback, + PromptDone, + is_openai_compatible_agent, +) from handlers.agents._shared import ( build_initial_context, build_mcp_servers_for_session, @@ -34,8 +41,8 @@ @dataclass class AgentSession: chat_id: int - agent_key: str # "claude-code", "gemini", "codex" - client: ACPClient + agent_key: str # "claude-code", "gemini", "lm-studio" + client: ACPClient | OpenAICompatibleClient mode: str = "condor" # "condor", "agent_builder" is_busy: bool = False _lock: asyncio.Lock = field(default_factory=asyncio.Lock) @@ -102,30 +109,41 @@ async def get_or_create_session( await _destroy_session_internal(chat_id) # Create new session - command = ACP_COMMANDS.get(agent_key, ACP_COMMANDS["claude-code"]) - extra_env = { "CONDOR_CHAT_ID": str(chat_id), } + supports_mcp = not is_openai_compatible_agent(agent_key) + # Build dynamic MCP servers from user's Condor permissions mcp_servers: list[dict] = [] if user_id: mcp_servers = build_mcp_servers_for_session(user_id, chat_id, user_data) - client = ACPClient( - command=command, - working_dir=get_project_dir(), - mcp_servers=mcp_servers, - permission_callback=permission_callback, - extra_env=extra_env, - ) + if is_openai_compatible_agent(agent_key): + client: ACPClient | OpenAICompatibleClient = OpenAICompatibleClient( + working_dir=get_project_dir(), + permission_callback=permission_callback, + extra_env=extra_env, + mcp_servers=mcp_servers, + ) + else: + command = ACP_COMMANDS.get(agent_key, ACP_COMMANDS["claude-code"]) + client = ACPClient( + command=command, + working_dir=get_project_dir(), + mcp_servers=mcp_servers, + permission_callback=permission_callback, + extra_env=extra_env, + ) await client.start() # Send initial context about server and permissions if user_id: - initial_context = build_initial_context(user_id, chat_id, user_data) + initial_context = build_initial_context( + user_id, chat_id, user_data, supports_mcp=supports_mcp + ) if initial_context: try: await client.prompt(initial_context) diff --git a/mcp_servers/condor/server.py b/mcp_servers/condor/server.py index 57583832..a3d515ab 100644 --- a/mcp_servers/condor/server.py +++ b/mcp_servers/condor/server.py @@ -130,8 +130,8 @@ def _resolve_routine(name: str): if name in agent_routines: return agent_routines[name] - from routines.base import discover_routines - return discover_routines(force_reload=True).get(name) + from routines.base import get_routine + return get_routine(name) def _local_manage_routines_describe(name: str) -> dict: @@ -519,15 +519,11 @@ def _local_journal_read(params: dict) -> dict: engine = get_engine(agent_id) if engine: - if engine.is_experiment: - return {"content": "(experiment mode — no journal, results saved to dry_runs/)"} session_dir = engine.session_dir agent_dir = engine.strategy.agent_dir else: from condor.trading_agent.journal import resolve_agent_dirs session_dir, agent_dir = resolve_agent_dirs(agent_id) - if not session_dir: - return {"content": "(no journal available for this agent)"} jm = JournalManager(agent_id, session_dir=session_dir, agent_dir=agent_dir) section = params.get("section", "recent") @@ -565,15 +561,11 @@ def _local_journal_write(params: dict) -> dict: engine = get_engine(agent_id) if engine: - if engine.is_experiment: - return {"error": "experiments don't have a journal — use dry_runs/ for results"} session_dir = engine.session_dir agent_dir = engine.strategy.agent_dir else: from condor.trading_agent.journal import resolve_agent_dirs session_dir, agent_dir = resolve_agent_dirs(agent_id) - if not session_dir: - return {"error": "no journal available for this agent"} jm = JournalManager(agent_id, session_dir=session_dir, agent_dir=agent_dir) entry_type = params.get("entry_type", "action") @@ -796,11 +788,9 @@ async def manage_trading_agent( name: Strategy name (for create/update) or routine name (for run_routine). description: Strategy description (for create/update). instructions: Strategy instructions text (for create/update). - agent_key: Default LLM for the strategy (for create/update). Examples: "claude-code", "gemini", "ollama:llama3.1", "ollama:qwen3:32b", "groq:llama-3.3-70b-versatile". Default "claude-code". + agent_key: Agent type "claude-code", "gemini", or "lm-studio" (for create, default "claude-code"). skills: List of optional skill names to enable (for create/update). config: Agent config overrides (for create/update/start) or routine config (for run_routine). - For start_agent, supports: agent_key (override strategy default), model_base_url (for LM Studio/vLLM), - execution_mode, frequency_sec, total_amount_quote, trading_context, risk_limits, server_name, max_ticks. Returns: Action-specific result dict. @@ -1045,15 +1035,11 @@ def _local_agent_monitoring(action: str, agent_id: str | None) -> dict: engine = get_engine(agent_id) if engine: - if engine.is_experiment: - return {"error": "experiments don't have a journal — use dry_runs/ for results"} session_dir = engine.session_dir agent_dir = engine.strategy.agent_dir else: from condor.trading_agent.journal import resolve_agent_dirs session_dir, agent_dir = resolve_agent_dirs(agent_id) - if not session_dir: - return {"error": "no journal available for this agent"} jm = JournalManager(agent_id, session_dir=session_dir, agent_dir=agent_dir) if action == "agent_tracker": @@ -1107,6 +1093,7 @@ async def manage_skills( def _local_manage_skills_list() -> dict: from condor.trading_agent.providers import list_providers + from condor.trading_agent.skill_loader import list_skills as list_skill_files items = [] for p in list_providers(): @@ -1115,17 +1102,36 @@ def _local_manage_skills_list() -> dict: "is_core": p.is_core, "type": "provider", }) + for s in list_skill_files(): + items.append({ + "name": s.name, + "is_core": False, + "type": "skill", + "description": s.description, + }) return {"skills": items} def _local_skill_test(name: str, config: dict) -> dict: + from condor.trading_agent.skill_loader import load_skill, _render_placeholders from condor.trading_agent.providers import get_provider + # Check if it's a data provider (needs API client -- can't test locally) provider = get_provider(name) if provider: return {"error": f"Provider '{name}' requires the Condor bot to be running for testing (needs API client)"} - return {"error": f"Skills have been removed; use manage_routines instead. Provider '{name}' not found."} + # Test SKILL.md file (can render locally) + skill_info = load_skill(name) + if skill_info: + rendered = _render_placeholders(skill_info.body, config) + return { + "name": skill_info.name, + "description": skill_info.description, + "rendered_prompt": rendered, + } + + return {"error": f"Skill or provider '{name}' not found"} if __name__ == "__main__":