diff --git a/.gitignore b/.gitignore index 4da07d4..a53f908 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ Thumbs.db .idea/ *.swp *.swo +keepsailing.es/ \ No newline at end of file diff --git a/mcp.json b/mcp.json index 2a212c6..fe5b83f 100644 --- a/mcp.json +++ b/mcp.json @@ -6,6 +6,18 @@ "env": {}, "timeout": 30, "startup_timeout": 10 + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp", "--headless"], + "timeout": 30, + "startup_timeout": 15 + }, + "fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"], + "timeout": 30, + "startup_timeout": 15 } } } diff --git a/mcp.json.example b/mcp.json.example index de634f0..fe5b83f 100644 --- a/mcp.json.example +++ b/mcp.json.example @@ -3,17 +3,21 @@ "acai-code": { "command": "node", "args": ["mcp-server/stdio.js"], - "env": { - "ACAI_WEB_URL": "http://localhost:8080", - "ACAI_WEBSITE": "mi-sitio", - "ACAI_PROJECT_DIR": "/ruta/al/proyecto" - }, + "env": {}, "timeout": 30, "startup_timeout": 10 }, - "filesystem": { + "playwright": { "command": "npx", - "args": ["@modelcontextprotocol/server-filesystem", "/tmp"] + "args": ["@playwright/mcp", "--headless"], + "timeout": 30, + "startup_timeout": 15 + }, + "fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"], + "timeout": 30, + "startup_timeout": 15 } } } diff --git a/src/mcp/manager.py b/src/mcp/manager.py index 5b66632..8d958d4 100644 --- a/src/mcp/manager.py +++ b/src/mcp/manager.py @@ -214,26 +214,47 @@ class MCPManager: # ------------------------------------------------------------------ def _namespace(self, server_name: str, tool_name: str) -> str: - """Build namespaced tool name. Skip prefix in single-server mode.""" + """Build namespaced tool name. Skip prefix in single-server mode. + + Uses '__' as separator because OpenAI requires tool names to + match ^[a-zA-Z0-9_-]+$ (no dots allowed). + """ if self._single_server_mode: return tool_name - return f"{server_name}.{tool_name}" + safe_server = server_name.replace("-", "_") + safe_tool = tool_name.replace("-", "_") + return f"{safe_server}__{safe_tool}" def _resolve_tool(self, namespaced_name: str) -> tuple[str, str]: """Resolve a namespaced tool name to (server_name, raw_tool_name).""" # Direct lookup in index if namespaced_name in self._tool_index: server_name = self._tool_index[namespaced_name] - raw_name = namespaced_name - if not self._single_server_mode and "." in namespaced_name: - raw_name = namespaced_name.split(".", 1)[1] - return server_name, raw_name + # Reverse the namespace to get original tool name + if not self._single_server_mode: + safe_server = server_name.replace("-", "_") + prefix = safe_server + "__" + if namespaced_name.startswith(prefix): + # Find original tool name by matching against client's tools + suffix = namespaced_name[len(prefix):] + for original_name in self._clients[server_name].tools: + if original_name.replace("-", "_") == suffix: + return server_name, original_name + # Fallback: try suffix directly + return server_name, suffix + return server_name, namespaced_name - # Try splitting on first dot - if "." in namespaced_name: - server_name, raw_name = namespaced_name.split(".", 1) - if server_name in self._clients: - return server_name, raw_name + # Try splitting on '__' + if "__" in namespaced_name: + parts = namespaced_name.split("__", 1) + prefix, suffix = parts + for server_name in self._clients: + if server_name.replace("-", "_") == prefix: + # Find original tool name + for original_name in self._clients[server_name].tools: + if original_name.replace("-", "_") == suffix: + return server_name, original_name + return server_name, suffix # Fallback: search all servers for the bare name for server_name, client in self._clients.items(): diff --git a/src/orchestrator/agents/base.py b/src/orchestrator/agents/base.py index 4cf5da1..c82d5f2 100644 --- a/src/orchestrator/agents/base.py +++ b/src/orchestrator/agents/base.py @@ -77,7 +77,7 @@ class BaseAgent: full_text = "" tool_calls: list[dict[str, Any]] = [] - current_tool: dict[str, Any] = {} + current_tool: dict[str, Any] | None = None async for chunk in self.model.stream( messages=ctx.to_messages(), @@ -96,7 +96,7 @@ class BaseAgent: session_id=session.session_id, ) - if chunk.tool_name and not current_tool.get("name"): + if chunk.tool_name and (current_tool is None or not current_tool.get("name")): current_tool = { "id": chunk.tool_call_id, "name": chunk.tool_name, @@ -108,18 +108,23 @@ class BaseAgent: session_id=session.session_id, ) - if chunk.tool_arguments and current_tool: + if chunk.tool_arguments and current_tool is not None and not chunk.finish_reason: + # Accumulate partial argument chunks (NOT the final one) current_tool["arguments"] += chunk.tool_arguments - if chunk.finish_reason == "tool_use" and current_tool.get("name"): - # Parse arguments + if chunk.finish_reason == "tool_use" and current_tool is not None and current_tool.get("name"): + # Final chunk carries complete arguments — use those if + # partial accumulation is empty, otherwise use accumulated + final_args = current_tool["arguments"] or chunk.tool_arguments or "" try: - args = json.loads(current_tool["arguments"]) if current_tool["arguments"] else {} + args = json.loads(final_args) if final_args else {} except json.JSONDecodeError: + logger.warning("Failed to parse tool args: %s", final_args[:200]) args = {} current_tool["parsed_arguments"] = args + logger.debug("Tool call finalized: %s args=%s", current_tool["name"], json.dumps(args)[:200]) tool_calls.append(current_tool) - current_tool = {} + current_tool = None if chunk.finish_reason == "end_turn": break @@ -168,6 +173,8 @@ class BaseAgent: status=ToolExecutionStatus.RUNNING, ) + logger.info("Tool call: %s(%s)", tool_name, json.dumps(arguments)[:200]) + start = time.monotonic() try: if self.mcp.is_running and tool_name in self.mcp.tools: