diff --git a/Dockerfile b/Dockerfile index 1d792a24..8289d79c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,12 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-reco libpq5 git curl jq man-db manpages procps file \ && rm -rf /var/lib/apt/lists/* +# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github) +COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:24-slim /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + # Non-root user RUN useradd --create-home --shell /bin/bash turnstone diff --git a/compose.yaml b/compose.yaml index 210892ca..805f089e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,7 +6,6 @@ # Single node: docker compose --profile production up # Production (PG): DB_BACKEND=postgresql docker compose --profile production up # 10-node cluster: docker compose --profile cluster up -# Cluster + DDG: docker compose --profile ddgCluster up # ============================================================================= name: turnstone @@ -28,7 +27,6 @@ services: profiles: - production - cluster - - ddgCluster command: - postgres - -c @@ -82,7 +80,6 @@ services: - "${SERVER_PORT:-8080}:8080" volumes: - turnstone-data:/data - - ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro environment: - LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1} - OPENAI_API_KEY=${OPENAI_API_KEY:-dummy} @@ -105,9 +102,6 @@ services: postgres: condition: service_healthy required: false - ddg-search: - condition: service_healthy - required: false healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] interval: 10s @@ -157,7 +151,6 @@ services: profiles: - production - cluster - - ddgCluster command: - sh - -c @@ -181,39 +174,6 @@ services: required: false restart: unless-stopped - # ------------------------------------------------------------------- - # ddg-search — DuckDuckGo Search MCP server (HTTP transport) - # Provides web search + content fetch tools to turnstone via MCP. - # No API key required. - # - # Start with: MCP_CONFIG=/etc/turnstone/mcp-ddg.json \ - # docker compose --profile ddgCluster up - # ------------------------------------------------------------------- - ddg-search: - image: python:3.14-slim - profiles: - - ddgCluster - command: - - sh - - -c - - >- - pip install --no-cache-dir duckduckgo-mcp-server && - python -c "from mcp.server.transport_security import TransportSecuritySettings; import duckduckgo_mcp_server.server as s; s.safe_search=s.SafeSearchMode.OFF; s.mcp.settings.host='0.0.0.0'; s.mcp.settings.port=3000; s.mcp.settings.transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False); s.mcp.run(transport='streamable-http')" - networks: - - turnstone-net - healthcheck: - test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('0.0.0.0',3000),2); s.close()\""] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s - deploy: - resources: - limits: - memory: 256M - cpus: '0.25' - restart: unless-stopped - # =================================================================== # 10-node cluster (profile: cluster) # @@ -228,7 +188,7 @@ services: server-1: &cluster-server image: turnstone:local build: { context: ., dockerfile: Dockerfile } - profiles: [cluster, ddgCluster] + profiles: [cluster] command: - sh - -c @@ -243,7 +203,6 @@ services: $${MCP_CONFIG:+--mcp-config $$MCP_CONFIG} volumes: - turnstone-data:/data - - ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro environment: &cluster-server-env LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1} OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy} @@ -262,7 +221,6 @@ services: networks: [turnstone-net] depends_on: postgres: { condition: service_healthy } - ddg-search: { condition: service_healthy, required: false } healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] interval: 10s diff --git a/docker/mcp-ddg.json b/docker/mcp-ddg.json deleted file mode 100644 index 55ba71d7..00000000 --- a/docker/mcp-ddg.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "mcpServers": { - "ddg": { - "url": "http://ddg-search:3000/mcp" - } - } -} diff --git a/docs/tools.md b/docs/tools.md index 5f26a984..2212f76c 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -593,7 +593,7 @@ current turn and letting it search for them on demand. Tool search uses the best available mechanism for each provider: 1. **Anthropic (native)** -- Models that support it receive `defer_loading: true` - on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side + on deferred tool definitions plus the `tool_search_tool_bm25` server-side search tool. Anthropic's API handles search and expansion transparently. 2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive diff --git a/tests/test_providers.py b/tests/test_providers.py index 8996ced5..4e97a1c8 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -2303,8 +2303,8 @@ class TestAnthropicToolSearch: # MCP tool should be deferred assert result[1]["defer_loading"] is True # Search tool should be appended - assert result[-1]["type"] == "tool_search_tool_bm25_20251119" - assert result[-1]["name"] == "tool_search" + assert result[-1]["type"] == "tool_search_tool_bm25" + assert result[-1]["name"] == "tool_search_tool_bm25" def test_inject_tool_search_no_op_without_deferred(self, provider): caps = provider.get_capabilities("claude-opus-4-6-20260101") diff --git a/turnstone/core/providers/_anthropic.py b/turnstone/core/providers/_anthropic.py index 84975408..587abe3c 100644 --- a/turnstone/core/providers/_anthropic.py +++ b/turnstone/core/providers/_anthropic.py @@ -69,7 +69,7 @@ def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: _WEB_SEARCH_TOOL_TYPE = "web_search_20250305" # Tool search: server-side BM25 tool discovery for deferred tools -_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25_20251119" +_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25" # -- model capabilities ------------------------------------------------------- @@ -205,7 +205,7 @@ class AnthropicProvider: result.append({**tool, "defer_loading": True}) else: result.append(tool) - result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": "tool_search"}) + result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": _TOOL_SEARCH_TOOL_TYPE}) return result # -- shared param logic -------------------------------------------------- diff --git a/turnstone/core/session.py b/turnstone/core/session.py index c2236c71..83b232da 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -980,6 +980,13 @@ class ChatSession: self._msg_tokens = [ max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages ] + log.info( + "Resuming ws=%s: %d messages, provider=%s, model=%s", + ws_id, + len(messages), + type(self._provider).__name__, + self.model, + ) # Restore persisted config config = load_workstream_config(ws_id) if config: @@ -997,11 +1004,24 @@ class ChatSession: self.context_window = cfg.context_window if not self._manual_tool_truncation: self.tool_truncation = int(cfg.context_window * self._chars_per_token * 0.5) + log.info( + "Resume: resolved alias=%s → provider=%s, model=%s, ctx=%d", + saved_alias, + type(self._provider).__name__, + model_name, + cfg.context_window, + ) elif saved_model and saved_model != self.model: # No alias or alias no longer in registry — at least set the model name self.model = saved_model self._model_alias = None self._cached_capabilities = None + log.warning( + "Resume: alias %r not in registry, keeping default provider=%s for model=%s", + saved_alias, + type(self._provider).__name__, + saved_model, + ) if "temperature" in config: self.temperature = float(config["temperature"]) if "reasoning_effort" in config: @@ -1368,6 +1388,21 @@ class ChatSession: ) -> Iterator[StreamChunk]: """Attempt a streaming API call with retries on transient errors.""" prov = provider or self._provider + raw_url = str(getattr(client, "base_url", getattr(client, "_base_url", "?"))) + safe_url = raw_url.split("?")[0] # strip query params (may contain keys) + msg_count = len(msgs) + role_counts: dict[str, int] = {} + for m in msgs: + r = m.get("role", "?") + role_counts[r] = role_counts.get(r, 0) + 1 + log.debug( + "API call: provider=%s model=%s base_url=%s msgs=%d roles=%s", + type(prov).__name__, + model, + safe_url, + msg_count, + role_counts, + ) last_err: Exception | None = None for attempt in range(self._MAX_RETRIES + 1): self._check_cancelled() @@ -1387,6 +1422,29 @@ class ChatSession: ) except Exception as e: ename = type(e).__name__ + cause_name = ( + type(e.__cause__).__name__ + if e.__cause__ + else (type(e.__context__).__name__ if e.__context__ else "None") + ) + log.warning( + "API error (attempt %d/%d): %s (cause=%s) " + "provider=%s model=%s base_url=%s msgs=%d", + attempt + 1, + self._MAX_RETRIES + 1, + ename, + cause_name, + type(prov).__name__, + model, + safe_url, + msg_count, + ) + log.debug( + "API error details (attempt %d/%d)", + attempt + 1, + self._MAX_RETRIES + 1, + exc_info=True, + ) if ename not in prov.retryable_error_names or attempt == self._MAX_RETRIES: raise last_err = e diff --git a/turnstone/server.py b/turnstone/server.py index bd1fede5..b2d9e84e 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -2101,7 +2101,12 @@ def internal_mcp_reload(request: Request) -> JSONResponse: mcp_mgr = MCPClientManager({}) mcp_mgr.start() + mcp_mgr.set_storage(storage) request.app.state.mcp_client = mcp_mgr + # Update shared ref so session_factory sees the new client + mcp_ref = getattr(request.app.state, "mcp_ref", None) + if mcp_ref is not None: + mcp_ref[0] = mcp_mgr result = mcp_mgr.reconcile_sync(storage) return JSONResponse({"status": "ok", **result}) @@ -2450,6 +2455,7 @@ def create_app( health_monitor: Any = None, rate_limiter: Any = None, mcp_client: Any = None, + mcp_ref: list[Any] | None = None, registry: Any = None, idle_timeout: int = 0, node_id: str = "", @@ -2530,6 +2536,7 @@ def create_app( app.state.health_monitor = health_monitor app.state.rate_limiter = rate_limiter app.state.mcp_client = mcp_client + app.state.mcp_ref = mcp_ref app.state.registry = registry app.state.idle_timeout = idle_timeout app.state.node_id = node_id @@ -2756,6 +2763,9 @@ def main() -> None: refresh_interval=config_store.get("mcp.refresh_interval"), storage=_get_storage(), ) + # Mutable ref so session_factory always sees the latest MCP client, + # including ones created by internal_mcp_reload after startup. + _mcp_ref: list[Any] = [mcp_client] # Backend health monitor with circuit breaker from turnstone.core.healthcheck import BackendHealthMonitor @@ -2866,6 +2876,9 @@ def main() -> None: ) -> ChatSession: assert ui is not None r_client, r_model, r_cfg = registry.resolve(model_alias) + # Read MCP client from shared ref — may have been replaced after startup + # by internal_mcp_reload (Sync to Nodes) when no --mcp-config was passed. + live_mcp_client = _mcp_ref[0] uid = getattr(ui, "_user_id", "") or "" # Resolve username from user_id for system message context @@ -2900,7 +2913,7 @@ def main() -> None: auto_compact_pct=config_store.get("session.auto_compact_pct"), agent_max_turns=config_store.get("tools.agent_max_turns"), tool_truncation=config_store.get("tools.truncation"), - mcp_client=mcp_client, + mcp_client=live_mcp_client, registry=registry, model_alias=model_alias or registry.default, health_monitor=health_monitor, @@ -3031,6 +3044,7 @@ def main() -> None: health_monitor=health_monitor, rate_limiter=rate_limiter, mcp_client=mcp_client, + mcp_ref=_mcp_ref, registry=registry, idle_timeout=config_store.get("server.workstream_idle_timeout"), node_id=_node_id,