From f862e9a9992e50da8ec6259d1a94ed91b0bb842e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 30 May 2026 17:49:03 -0700 Subject: [PATCH 01/41] cli-41: allow agents load remove mcp --- cecli/tools/load_mcp_tool.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cecli/tools/load_mcp_tool.py diff --git a/cecli/tools/load_mcp_tool.py b/cecli/tools/load_mcp_tool.py new file mode 100644 index 00000000000..e69de29bb2d From 90ecacd167c14e1fc1d1c0bdd0cbaf48952c8927 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 21:33:18 -0700 Subject: [PATCH 02/41] feat: Update test implementation and cases for MCP tools Co-authored-by: cecli (openai/code) --- tests/tools/test_load_mcp_tool.py | 211 ++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tests/tools/test_load_mcp_tool.py diff --git a/tests/tools/test_load_mcp_tool.py b/tests/tools/test_load_mcp_tool.py new file mode 100644 index 00000000000..d73d340dc5d --- /dev/null +++ b/tests/tools/test_load_mcp_tool.py @@ -0,0 +1,211 @@ +"""Unit tests for LoadMcpTool.execute.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from cecli.tools.load_mcp_tool import LoadMcpTool + + +class DummyIO: + """Mock IO object for testing.""" + + def __init__(self): + self.tool_error = Mock() + self.tool_warning = Mock() + self.tool_output = Mock() + self.interrupt_event = Mock() + + +class DummyCoder: + """Mock Coder object for testing.""" + + def __init__(self): + self.io = DummyIO() + self.mcp_manager = Mock() + self.mcp_manager.servers = [] + self.mcp_manager.connected_servers = {} + self.coroutines = Mock() + self.coroutines.interruptible = AsyncMock() + self.interrupt_event = Mock() + + +@pytest.fixture +def coder(): + """Provide a dummy coder for testing.""" + return DummyCoder() + + +@pytest.fixture +def mock_server(): + """Provide a mock MCP server.""" + server = Mock() + server.name = "test-server" + server.config = {"enabled": True} + return server + + +class TestLoadMcpTool: + """Test cases for LoadMcpTool.""" + + @pytest.mark.asyncio + async def test_no_mcp_servers_found(self, coder): + """Test when no MCP servers are configured.""" + coder.mcp_manager.servers = [] + result = await LoadMcpTool.execute(coder, servers=["test"]) + assert result == "No MCP servers found, nothing to load." + + @pytest.mark.asyncio + async def test_server_not_found(self, coder, mock_server): + """Test when requested server doesn't exist.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.get_server.return_value = None + result = await LoadMcpTool.execute(coder, servers=["nonexistent"]) + assert "MCP server nonexistent does not exist." in result + + @pytest.mark.asyncio + async def test_server_already_loaded(self, coder, mock_server): + """Test when server is already loaded.""" + mock_server.name = "test-server" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + # Must return tuple (did_connect, interrupted) + coder.coroutines.interruptible.return_value = (True, False) + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Server already loaded: test-server" in result + + @pytest.mark.asyncio + async def test_server_not_enabled_by_default(self, coder, mock_server): + """Test when server is not enabled by default.""" + mock_server.config = {"enabled": False} + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.get_server.return_value = mock_server + result = await LoadMcpTool.execute(coder, servers=["*"]) + assert "Skipping server (not enabled by default): test-server" in result + + @pytest.mark.asyncio + async def test_successful_load(self, coder, mock_server): + """Test successful server loading.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server + coder.coroutines.interruptible.return_value = (True, False) + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Loaded server: test-server" in result + + @pytest.mark.asyncio + async def test_load_interrupted(self, coder, mock_server): + """Test when loading is interrupted.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server + coder.coroutines.interruptible.return_value = (False, True) + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Interrupted: test-server" in result + + @pytest.mark.asyncio + async def test_load_failed(self, coder, mock_server): + """Test when loading fails.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server + coder.coroutines.interruptible.return_value = (False, False) + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Unable to load server: test-server" in result + + @pytest.mark.asyncio + async def test_load_all_servers(self, coder): + """Test loading all servers with '*' wildcard.""" + server1 = Mock() + server1.name = "server1" + server1.config = {"enabled": True} + server2 = Mock() + server2.name = "server2" + server2.config = {"enabled": True} + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + coder.coroutines.interruptible.return_value = (True, False) + result = await LoadMcpTool.execute(coder, servers=["*"]) + assert "Loaded server: server1" in result + assert "Loaded server: server2" in result + + @pytest.mark.asyncio + async def test_mixed_results(self, coder): + """Test mixed success/failure results.""" + server1 = Mock() + server1.name = "server1" + server1.config = {"enabled": True} + server2 = Mock() + server2.name = "server2" + server2.config = {"enabled": True} + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + + async def mock_interruptible_func(*args, **kwargs): + # First call succeeds, second fails + if not hasattr(mock_interruptible_func, "call_count"): + mock_interruptible_func.call_count = 0 + mock_interruptible_func.call_count += 1 + if mock_interruptible_func.call_count == 1: + return (True, False) + else: + return (False, False) + + coder.coroutines.interruptible.side_effect = mock_interruptible_func + result = await LoadMcpTool.execute(coder, servers=["server1", "server2"]) + assert "Loaded server: server1" in result + assert "Unable to load server: server2" in result + + @pytest.mark.asyncio + async def test_duplicate_iteration_bug_fix(self, coder, mock_server): + """Test that duplicate iteration bug is fixed - server already loaded only processed once.""" + mock_server.name = "test-server" + coder.mcp_manager.servers = [mock_server] + # Server already connected + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + + # Should only report server already loaded once + assert result.count("Server already loaded: test-server") == 1 + # connect_server should not have been called since it was already loaded + coder.mcp_manager.connect_server.assert_not_called() + + @pytest.mark.asyncio + async def test_wildcard_with_duplicate_iteration_fix(self, coder): + """Test wildcard loading with duplicate iteration fix.""" + server1 = Mock() + server1.name = "server1" + server1.config = {"enabled": True} + server2 = Mock() + server2.name = "server2" + server2.config = {"enabled": True} + coder.mcp_manager.servers = [server1, server2] + # server1 already loaded, server2 not loaded + coder.mcp_manager.connected_servers = {"server1": server1} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + + # Track calls to connect_server + connect_calls = [] + + async def mock_connect(server_name): + connect_calls.append(server_name) + return True, False + + coder.mcp_manager.connect_server.side_effect = mock_connect + result = await LoadMcpTool.execute(coder, servers=["*"]) + + # Should only attempt to load server2 (server1 should be skipped) + assert "Server already loaded: server1" in result + assert "Loaded server: server2" in result + assert connect_calls == ["server2"] # Only server2 should have been connected From f800aa5e7e31425df496d891a298c0b64c8d73b9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 23:44:46 -0700 Subject: [PATCH 03/41] fix: Update load_mcp_tool and its tests Co-authored-by: cecli (openai/code) --- cecli/tools/load_mcp_tool.py | 78 +++++++++++++++++++++++++++++++ tests/tools/test_load_mcp_tool.py | 13 +++--- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/cecli/tools/load_mcp_tool.py b/cecli/tools/load_mcp_tool.py index e69de29bb2d..c82f467a42b 100644 --- a/cecli/tools/load_mcp_tool.py +++ b/cecli/tools/load_mcp_tool.py @@ -0,0 +1,78 @@ +from typing import List + +from cecli.tools.utils.base_tool import BaseTool + + +class LoadMcpTool(BaseTool): + NORM_NAME = "load-mcp" + SCHEMA = { + "type": "function", + "function": { + "name": "load-mcp", + "description": "Load MCP server(s) by name, or use '*' to load all enabled servers.", + "parameters": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": {"type": "string"}, + "description": "A list of MCP server names to load. Use '*' to load all.", + } + }, + "required": ["servers"], + }, + }, + } + + @classmethod + async def execute(cls, coder, servers: List[str]): + """Execute the load-mcp tool with given parameters.""" + if not coder.mcp_manager or not coder.mcp_manager.servers: + return "No MCP servers found, nothing to load." + + results = [] + servers_to_load = [] + + if servers == ["*"]: + for server in coder.mcp_manager.servers: + if server.name in coder.mcp_manager.connected_servers: + results.append(f"Server already loaded: {server.name}") + continue + auto_connect = server.config.get("enabled", True) + if not auto_connect: + results.append(f"Skipping server (not enabled by default): {server.name}") + continue + servers_to_load.append(server) + else: + for server_name in servers: + server = coder.mcp_manager.get_server(server_name) + if server is None: + results.append(f"MCP server {server_name} does not exist.") + else: + servers_to_load.append(server) + + if not servers_to_load and results: + return "\n".join(results) + + # Process the loading + for server in servers_to_load: + server_name = server.name + if server_name in coder.mcp_manager.connected_servers: + results.append(f"Server already loaded: {server_name}") + continue + + coder.interrupt_event.clear() + did_connect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.connect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + results.append(f"Interrupted: {server_name}") + continue + if did_connect: + results.append(f"Loaded server: {server_name}") + else: + results.append(f"Unable to load server: {server_name}") + + return "\n".join(results) diff --git a/tests/tools/test_load_mcp_tool.py b/tests/tools/test_load_mcp_tool.py index d73d340dc5d..b168c54d270 100644 --- a/tests/tools/test_load_mcp_tool.py +++ b/tests/tools/test_load_mcp_tool.py @@ -195,14 +195,15 @@ async def test_wildcard_with_duplicate_iteration_fix(self, coder): (s for s in [server1, server2] if s.name == name), None ) - # Track calls to connect_server - connect_calls = [] + coder.coroutines.interruptible.return_value = (True, False) - async def mock_connect(server_name): - connect_calls.append(server_name) - return True, False + async def mock_interruptible(coro, event): + server_name = coro.cr_frame.f_locals["server_name"] + if server_name == "server2": + return True, False + return False, False - coder.mcp_manager.connect_server.side_effect = mock_connect + coder.coroutines.interruptible.side_effect = mock_interruptible result = await LoadMcpTool.execute(coder, servers=["*"]) # Should only attempt to load server2 (server1 should be skipped) From a2edd66be768c801a81d6b87eb10c05f54e8a5db Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 16 Jun 2026 02:09:06 -0700 Subject: [PATCH 04/41] fix: Correctly mock async function and test non-existent server Co-authored-by: cecli (openai/code) --- tests/unit/test_remove_mcp_tool.py | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/unit/test_remove_mcp_tool.py diff --git a/tests/unit/test_remove_mcp_tool.py b/tests/unit/test_remove_mcp_tool.py new file mode 100644 index 00000000000..39ff5c8275b --- /dev/null +++ b/tests/unit/test_remove_mcp_tool.py @@ -0,0 +1,103 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from cecli.tools.remove_mcp_tool import RemoveMcpTool + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_success(): + """Test successful removal of an MCP server.""" + # Setup + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + server.config.get.return_value = True # auto_connect enabled + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {"test-server": server} + # Mock disconnect_server as an AsyncMock that returns (True, False) + coder.mcp_manager.disconnect_server = AsyncMock(return_value=(True, False)) + + # Mock the interruptible method to execute the coroutine + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines = MagicMock() + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + # Execute + result = await RemoveMcpTool.execute(coder, ["test-server"]) + + # Assertions + assert "Removed server: test-server" in result + coder.mcp_manager.disconnect_server.assert_awaited_once_with("test-server") + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_non_existent(): + """Test removing a non-existent MCP server.""" + # Setup + coder = MagicMock() + coder.mcp_manager = MagicMock() + # Create a mock server that exists (to bypass the 'no servers' check) + existing_server = MagicMock() + existing_server.name = "existing-server" + existing_server.config.get.return_value = True + coder.mcp_manager.servers = [existing_server] + # But the one we're looking for doesn't exist + coder.mcp_manager.get_server.return_value = None + + # Execute + result = await RemoveMcpTool.execute(coder, ["non-existent-server"]) + + # Assertions + assert "MCP server non-existent-server does not exist." in result + + assert "MCP server non-existent-server does not exist." in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_not_connected(): + """Test removing a server that is not connected.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.servers = [server] + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {} + + result = await RemoveMcpTool.execute(coder, ["test-server"]) + + assert "Server test-server is not currently connected." in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_wildcard(): + """Test removing all servers with wildcard '*'.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + + server1 = MagicMock() + server1.name = "server1" + server2 = MagicMock() + server2.name = "server2" + + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.disconnect_server = AsyncMock(return_value=True) + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines = MagicMock() + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await RemoveMcpTool.execute(coder, ["*"]) + + assert "Removed server: server1" in result + assert "Removed server: server2" in result + assert coder.mcp_manager.disconnect_server.await_count == 2 From b8d1b421c15abf4415628a2f31c08bc84b49c525 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 16 Jun 2026 02:42:19 -0700 Subject: [PATCH 05/41] fix: Correct test_load_mcp_tool_wildcard_and_duplicate_fix assertions Co-authored-by: cecli (openai/code) --- tests/unit/test_load_mcp_tool.py | 134 +++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/unit/test_load_mcp_tool.py diff --git a/tests/unit/test_load_mcp_tool.py b/tests/unit/test_load_mcp_tool.py new file mode 100644 index 00000000000..87c8706dbd7 --- /dev/null +++ b/tests/unit/test_load_mcp_tool.py @@ -0,0 +1,134 @@ +"""Unit tests for load-mcp tool.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from cecli.tools.load_mcp_tool import LoadMcpTool + + +@pytest.fixture +def mock_mcp_manager(): + """Fixture for a mocked McpServerManager.""" + manager = MagicMock() + manager.connected_servers = {} + + # Mock servers + server1 = MagicMock() + server1.name = "test-server" + server1.config = {"enabled": True} + server2 = MagicMock() + server2.name = "server2" + server2.config = {"enabled": True} + server3 = MagicMock() + server3.name = "server3" + server3.config = {"enabled": False} + + manager.servers = [server1, server2, server3] + + def get_server_side_effect(name): + if name == "test-server": + return server1 + if name == "server2": + return server2 + if name == "server3": + return server3 + return None + + manager.get_server.side_effect = get_server_side_effect + + async def connect(server_name): + manager.connected_servers[server_name] = "connected" + return True, False # (did_connect, interrupted) + + async def disconnect(server_name): + if server_name in manager.connected_servers: + del manager.connected_servers[server_name] + return True, False + return False, False + + manager.connect_server = AsyncMock(side_effect=connect) + manager.disconnect_server = AsyncMock(side_effect=disconnect) + manager.add_server = AsyncMock() + + return manager + + +@pytest.mark.asyncio +async def test_load_mcp_tool_success(mock_mcp_manager): + """Test loading a single MCP server successfully.""" + tool = LoadMcpTool() + # Mock the coder + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + coder.coroutines = MagicMock() + + # Mock interruptible to return (await coro, False) + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible.side_effect = mock_interruptible + + result = await tool.execute(coder, servers=["test-server"]) + + assert "Loaded server: test-server" in result + mock_mcp_manager.connect_server.assert_awaited_once_with("test-server") + + +@pytest.mark.asyncio +async def test_load_mcp_tool_non_existent(mock_mcp_manager): + """Test loading a non-existent MCP server.""" + tool = LoadMcpTool() + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + + result = await tool.execute(coder, servers=["non-existent-server"]) + + assert "MCP server non-existent-server does not exist." in result + mock_mcp_manager.connect_server.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_mcp_tool_already_loaded(mock_mcp_manager): + """Test loading an already loaded MCP server.""" + tool = LoadMcpTool() + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + # Pre-populate connected_servers + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.connected_servers = {"test-server": server} + + result = await tool.execute(coder, servers=["test-server"]) + + assert "Server already loaded: test-server" in result + mock_mcp_manager.connect_server.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_mcp_tool_wildcard_and_duplicate_fix(mock_mcp_manager): + """Test loading with wildcard and duplicate fix.""" + tool = LoadMcpTool() + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + coder.coroutines = MagicMock() + + # Mock interruptible to return (await coro, False) + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible.side_effect = mock_interruptible + + # Set up connected_servers: server1 is already connected + server1 = mock_mcp_manager.get_server("test-server") + coder.mcp_manager.connected_servers = {"test-server": server1} + + result = await tool.execute(coder, servers=["*"]) + + # Check results + assert "Server already loaded: test-server" in result + assert "Loaded server: server2" in result + assert "Skipping server (not enabled by default): server3" in result + + # Verify connect_server was called only once for server2 + mock_mcp_manager.connect_server.assert_awaited_once_with("server2") From 9ca46ef7f88090a44baf6d9dcd42ff2b7a76ed6b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Jun 2026 09:26:27 -0400 Subject: [PATCH 06/41] #581: total_cost does not need to be propagated on coder class creation anymore --- cecli/coders/architect_coder.py | 1 - cecli/commands/agent_model.py | 1 - cecli/commands/editor_model.py | 1 - cecli/commands/model.py | 1 - cecli/commands/weak_model.py | 1 - 5 files changed, 5 deletions(-) diff --git a/cecli/coders/architect_coder.py b/cecli/coders/architect_coder.py index 9eb6e53d68e..99d1ddff92e 100644 --- a/cecli/coders/architect_coder.py +++ b/cecli/coders/architect_coder.py @@ -45,7 +45,6 @@ async def reply_completed(self): kwargs["args"] = self.args kwargs["suggest_shell_commands"] = False kwargs["map_tokens"] = 0 - kwargs["total_cost"] = self.total_cost kwargs["cache_prompts"] = False kwargs["num_cache_warming_pings"] = 0 kwargs["summarize_from_coder"] = False diff --git a/cecli/commands/agent_model.py b/cecli/commands/agent_model.py index ceaa02ebf5f..758bb0da41e 100644 --- a/cecli/commands/agent_model.py +++ b/cecli/commands/agent_model.py @@ -47,7 +47,6 @@ async def execute(cls, io, coder, args, **kwargs): kwargs["main_model"] = model kwargs["edit_format"] = coder.edit_format # Keep the same edit format kwargs["suggest_shell_commands"] = False - kwargs["total_cost"] = coder.total_cost kwargs["num_cache_warming_pings"] = 0 kwargs["summarize_from_coder"] = False kwargs["done_messages"] = [] diff --git a/cecli/commands/editor_model.py b/cecli/commands/editor_model.py index b76190a56d5..28725a20466 100644 --- a/cecli/commands/editor_model.py +++ b/cecli/commands/editor_model.py @@ -46,7 +46,6 @@ async def execute(cls, io, coder, args, **kwargs): kwargs["main_model"] = model kwargs["edit_format"] = coder.edit_format # Keep the same edit format kwargs["suggest_shell_commands"] = False - kwargs["total_cost"] = coder.total_cost kwargs["num_cache_warming_pings"] = 0 kwargs["summarize_from_coder"] = False kwargs["done_messages"] = [] diff --git a/cecli/commands/model.py b/cecli/commands/model.py index 83bc18e6e5f..f09c9fc64c2 100644 --- a/cecli/commands/model.py +++ b/cecli/commands/model.py @@ -50,7 +50,6 @@ async def execute(cls, io, coder, args, **kwargs): kwargs["main_model"] = model kwargs["edit_format"] = new_edit_format kwargs["suggest_shell_commands"] = False - kwargs["total_cost"] = coder.total_cost kwargs["num_cache_warming_pings"] = 0 kwargs["summarize_from_coder"] = False kwargs["done_messages"] = [] diff --git a/cecli/commands/weak_model.py b/cecli/commands/weak_model.py index acff8a48e30..12d79f6cffd 100644 --- a/cecli/commands/weak_model.py +++ b/cecli/commands/weak_model.py @@ -46,7 +46,6 @@ async def execute(cls, io, coder, args, **kwargs): kwargs["main_model"] = model kwargs["edit_format"] = coder.edit_format # Keep the same edit format kwargs["suggest_shell_commands"] = False - kwargs["total_cost"] = coder.total_cost kwargs["num_cache_warming_pings"] = 0 kwargs["summarize_from_coder"] = False kwargs["done_messages"] = [] From bb40e0009e6287079b84ff1d6688d5302f598922 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Jun 2026 12:57:29 -0400 Subject: [PATCH 07/41] Change hashpos system to use base 256 numeric system instead of base 64 so we can get away with 3 chars per content id --- cecli/helpers/hashline.py | 6 +- cecli/helpers/hashpos/hashpos.py | 145 ++++++++++++++----------------- cecli/tools/edit_text.py | 14 +-- tests/basic/test_hashline.py | 75 ++++++++-------- 4 files changed, 109 insertions(+), 131 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 4d985578b20..f03cb071ce8 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -41,7 +41,7 @@ def strip_hashline(text: str) -> str: def normalize_hashline(hashline_str: str) -> str: """ - Normalize a hashline string to the 4-character hash fragment. + Normalize a hashline string to the content id hash fragment. """ if hashline_str in ("@000", "000@"): return hashline_str @@ -479,8 +479,8 @@ def get_hashline_diff( # Strip line endings for difflib comparison but keep them in the actual lines diff = difflib.unified_diff( - [line.rstrip("\r\n") for line in find_lines], - [line.rstrip("\r\n") for line in replace_lines], + [strip_hashline(line.rstrip("\r\n")) for line in find_lines], + [strip_hashline(line.rstrip("\r\n")) for line in replace_lines], lineterm="", n=1, ) diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 516052012c9..280e3394ec4 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -4,37 +4,67 @@ class HashPos: - B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_" - # Regex pattern for HashPos format: {4-char-hash}:: - HASH_PREFIX_RE = re.compile(r"^([0-9a-zA-Z\~_@]{4})::") - # Regex for normalization: 4 hash chars optionally followed by '::' - NORMALIZE_RE = re.compile(r"^([0-9a-zA-Z\~_@]{4})(?:)?::") - # Regex for a raw 4-character fragment - FRAGMENT_RE = re.compile(r"^[0-9a-zA-Z\~_@]{4}$") + B256 = ( + "ABCDEFGHIJKLMNOP" + "QRSTUVWXYZabcdef" + "ghijklmnopqrstuv" + "wxyz0123456789~_" + "áéíóúñüöäßåøæçèà" + "ùîôûбгджзийлпфцч" + "шщъыьэюя的是不了人我在有" + "他这为之大来以个中上们到说国和学" + "あいうえおかきくけこさしすせそた" + "ちつてとアイウエオカキクケコサシ" + "スセソタチツテトαβγδεζηθ" + "ικλμνξπ要会出就道也时年得" + "生自下而过能可对行没发用天作方成" + "者多日都三小机把理实心看起样好当" + "点本民事其然想经去种动全意面前所" + "业定现将法新明问度但最美月手走信" + ) + + # We use a regex-safe character class string for compiling patterns + _B256_REGEX_SET = ( + "A-Za-z0-9~_" + "áéíóúñüöäßåøæçèà" + "ùîôûбгджзийлпфцч" + "шщъыьэюя的是不了人我在有" + "他这为之大来以个中上们到说国和学" + "あいうえおかきくけこさしすせそた" + "ちつてとアイウエオカキクケコサシ" + "スセソタチツテトαβγδεζηθ" + "ικλμνξπ要会出就道也时年得" + "生自下而过能可对行没发用天作方成" + "者多日都三小机把理实心看起样好当" + "点本民事其然想经去种动全意面前所" + "业定现将法新明问度但最美月手走信" + ) + + # Regex pattern for HashPos format: {3-char-hash}:: + HASH_PREFIX_RE = re.compile(rf"^([{_B256_REGEX_SET}]{{3}})::") + # Regex for normalization: 3 hash chars optionally followed by '::' + NORMALIZE_RE = re.compile(rf"^([{_B256_REGEX_SET}]{{3}})(?:)?::") + # Regex for a raw 3-character fragment + FRAGMENT_RE = re.compile(rf"^[{_B256_REGEX_SET}]{{3}}$") def __init__(self, source_text: str = ""): self.lines = source_text.splitlines() self.total = len(self.lines) - def _get_region_bits(self, line_idx: int) -> tuple[int, int]: + def _get_region_val(self, line_idx: int) -> int: """ - Uses line_idx modulo 16 (4 bits) to get two 2-bit flags (b1, b2). - This guarantees up to 16 consecutive repeating lines get unique spatial anchors. + Uses line_idx modulo 16 (4 bits). + Guarantees up to 16 consecutive repeating lines get unique spatial anchors. """ - mod_val = line_idx % 16 - - # Split the 4-bit modulo value into two separate 2-bit flags - b1 = (mod_val >> 2) & 3 # Top 2 bits (mask with 0b11) - b2 = mod_val & 3 # Bottom 2 bits - return b1, b2 + return line_idx % 16 def _get_neighborhood_hash(self, line_idx: int) -> int: """ Creates a 20-bit digest using the current line and the 3 lines before and after it. """ - start = max(0, line_idx - 3) - end = min(self.total, line_idx + 4) + start = max(0, line_idx - 2) + end = min(self.total, line_idx + 3) context_window = "\n".join(self.lines[start:end]) full_hash = xxhash.xxh3_64_intdigest(context_window.encode("utf-8")) @@ -51,22 +81,20 @@ def generate_private_id(self, text: str) -> str: def generate_public_id(self, text: str, line_idx: int) -> str: """ - Generates a 4-char Base64 ID combining modulo buckets and context hash. - Layout: [2-bit b1] [2-bit b2] [10-bit Hash A] [10-bit Hash B] + Generates a 3-char Base256 ID combining a 4-bit modulo bucket and a 20-bit context hash. + Layout: [4-bit Region] [20-bit Neighborhood Hash] = 24 bits total. + Each Base256 char holds 8 bits (3 chars * 8 = 24 bits). """ - b1, b2 = self._get_region_bits(line_idx) + region_val = self._get_region_val(line_idx) neighborhood_hash = self._get_neighborhood_hash(line_idx) - # Split the 20-bit hash into two 10-bit halves - hash_a = (neighborhood_hash >> 10) & 0x3FF - hash_b = neighborhood_hash & 0x3FF + # Pack the 24-bit integer + packed = (region_val << 20) | neighborhood_hash - # Construct the mixed 24-bit integer - packed = (b1 << 22) | (b2 << 20) | (hash_a << 10) | hash_b res = "" - for _ in range(4): - res += self.B64[packed % 64] - packed //= 64 + for _ in range(3): + res += self.B256[packed % 256] + packed //= 256 return res def unpack_public_id(self, public_id: str) -> tuple[int, int]: @@ -75,16 +103,13 @@ def unpack_public_id(self, public_id: str) -> tuple[int, int]: """ packed = 0 for i, char in enumerate(public_id): - packed |= self.B64.index(char) << (6 * i) + packed |= self.B256.index(char) << (8 * i) - b1 = (packed >> 22) & 3 - b2 = (packed >> 20) & 3 - hash_a = (packed >> 10) & 0x3FF - hash_b = packed & 0x3FF - mod_val = (b1 << 2) | b2 - neighborhood_hash = (hash_a << 10) | hash_b + # Extract the 4-bit region (mask 0xF) and 20-bit hash (mask 0xFFFFF) + region_val = (packed >> 20) & 0xF + neighborhood_hash = packed & 0xFFFFF - return mod_val, neighborhood_hash + return region_val, neighborhood_hash def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> str: formatted_lines = [] @@ -128,11 +153,6 @@ def modulo_distance(idx: int) -> int: def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: """ Resolves a block range from two Public IDs. - - Logic: - 1. Resolve all candidates for both IDs (sorted by best match). - 2. Find the pair of (start, end) that are logically ordered. - 3. Returns (start_index, end_index) """ starts = self.resolve_to_lines(start_id) ends = self.resolve_to_lines(end_id) @@ -146,28 +166,17 @@ def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: return s, e raise ValueError( - f"Found matches for {start_id} and {end_id}, but no logically ordered range or unique" - " matches." + f"Found matches for {start_id} and {end_id}, but no logically ordered range or unique matches." ) @staticmethod def strip_prefix(text: str) -> str: - r""" + """ Remove HashPos prefixes from the start of every line. - - Removes prefixes that match the pattern: "{4-char-hash}" - where the hash is exactly 4 characters from the set [0-9a-zA-Z\~_@] followed by '::'. - - Args: - text: Input text with HashPos prefixes - - Returns: - String with HashPos prefixes removed from each line """ lines = text.splitlines(keepends=True) result_lines = [] for line in lines: - # Remove the HashPos prefix if present stripped_line = HashPos.HASH_PREFIX_RE.sub("", line, count=1) result_lines.append(stripped_line) @@ -177,12 +186,6 @@ def strip_prefix(text: str) -> str: def extract_prefix(line: str) -> str: """ Extract the hash prefix from a line if it has a HashPos prefix. - - Args: - line: A line of text that may contain a HashPos prefix - - Returns: - The hash prefix (4 characters) if found, otherwise empty string """ match = HashPos.HASH_PREFIX_RE.match(line) if match: @@ -192,25 +195,11 @@ def extract_prefix(line: str) -> str: @staticmethod def normalize(hashpos_str: str) -> str: """ - Normalize a HashPos string to the 4-character hash fragment. - - Accepts HashPos strings in "{hash_prefix}::" format or a raw "{hash_prefix}" fragment. - Also extracts HashPos from strings that contain content after the HashPos, - e.g., "H7M5::Line 1" - - Args: - hashpos_str: HashPos string in various formats - - Returns: - str: The 4-character hash fragment - - Raises: - ValueError: If format is invalid + Normalize a HashPos string to the 3-character hash fragment. """ if hashpos_str is None: raise ValueError("HashPos string cannot be None") - # Check if it's already a raw fragment if HashPos.FRAGMENT_RE.match(hashpos_str): return hashpos_str @@ -218,9 +207,7 @@ def normalize(hashpos_str: str) -> str: if match: return match.group(1) - # If no pattern matches, raise error raise ValueError( f"Invalid HashPos format '{hashpos_str}'. " - r"Expected \"{content ID}\" " - r"where content ID is exactly 4 characters from the set [0-9a-zA-Z\~_@]." + r"Expected a 3-character string from the Base256 character set." ) diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index a22e96dcd57..87a21e9c232 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -437,7 +437,7 @@ def format_output(cls, coder, mcp_server, tool_response): start_line = edit.get("start_line") end_line = edit.get("end_line") # Show output based on operation type - if operation == "replace": + if operation in ("replace", "delete"): # Show diff for replace operations diff_output = "" @@ -451,7 +451,7 @@ def format_output(cls, coder, mcp_server, tool_response): original_content=strip_hashline(original_content), start_line_hash=start_line, end_line_hash=end_line, - operation="replace", + operation=operation, text=strip_hashline(text), ) except ContentHashError as e: @@ -469,14 +469,4 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output(text) coder.io.tool_output("") - elif operation == "delete": - # Show deletion summary - range_info = ( - f"Deleted {start_line} - {end_line}" - if start_line and end_line - else "specified range" - ) - coder.io.tool_output(range_info) - coder.io.tool_output("") - tool_footer(coder=coder, tool_response=tool_response, params=params) diff --git a/tests/basic/test_hashline.py b/tests/basic/test_hashline.py index 7cc846448a4..b15767ecabf 100644 --- a/tests/basic/test_hashline.py +++ b/tests/basic/test_hashline.py @@ -4,6 +4,7 @@ parse_hashline, strip_hashline, ) +from cecli.helpers.hashpos.hashpos import HashPos def test_hashline_basic(): @@ -15,17 +16,17 @@ def test_hashline_basic(): lines = result.splitlines() assert len(lines) == 3 - # Check each line has the format "{4-char-hash}::content" (new HashPos format) + # Check each line has the format "{3-char-hash}::content" (HashPos format) for i, line in enumerate(lines, start=1): - # Format should be "{4-char-hash}::content" + # Format should be "{3-char-hash}::content" assert "::" in line # Extract hash fragment (everything before "::") hash_fragment = line.split("::", 1)[0] - # Check hash fragment is 4 characters - assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) + # Check hash fragment is 3 characters + assert len(hash_fragment) == 3 + # Check all hash characters are valid B256 characters for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" + assert char in "".join(HashPos.B256) def test_hashline_with_start_line(): @@ -35,18 +36,18 @@ def test_hashline_with_start_line(): lines = result.splitlines() assert len(lines) == 2 - # Check format is {4-char-hash}::content (new HashPos format) + # Check format is {3-char-hash}::content (HashPos format) # Note: start_line parameter is ignored by HashPos but kept for compatibility for line in lines: - # Format should be "{4-char-hash}::content" + # Format should be "{3-char-hash}::content" assert "::" in line # Extract hash fragment (everything before "::") hash_fragment = line.split("::", 1)[0] - # Check hash fragment is 4 characters - assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) + # Check hash fragment is 3 characters + assert len(hash_fragment) == 3 + # Check all hash characters are valid B256 characters for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" + assert char in "".join(HashPos.B256) """Test hashline with empty string.""" result = hashline("") assert result == "" @@ -58,23 +59,23 @@ def test_hashline_single_line(): result = hashline(text) lines = result.splitlines() assert len(lines) == 1 - # Check format is {4-char-hash}::content (new HashPos format) + # Check format is {3-char-hash}::content (HashPos format) line = lines[0] assert "::" in line # Extract hash fragment (everything before "::") hash_fragment = line.split("::", 1)[0] - # Check hash fragment is 4 characters - assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) + # Check hash fragment is 3 characters + assert len(hash_fragment) == 3 + # Check all hash characters are valid B256 characters for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" + assert char in "".join(HashPos.B256) def test_hashline_preserves_newlines(): """Test that hashline preserves newline characters.""" text = "Line 1\nLine 2\n" result = hashline(text) - # HashPos format: {4-char-hash}::content on each line + # HashPos format: {3-char-hash}::content on each line # The result should have hashes on each line but no trailing newline lines = result.splitlines() assert len(lines) == 2 @@ -83,18 +84,18 @@ def test_hashline_preserves_newlines(): assert "::" in line # Extract hash fragment (everything before "::") hash_fragment = line.split("::", 1)[0] - assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) + assert len(hash_fragment) == 3 + # Check all hash characters are valid B256 characters for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" + assert char in "".join(HashPos.B256) # HashPos doesn't preserve trailing newlines in the formatted output # The splitlines() above verifies we have the right number of lines def test_strip_hashline_basic(): """Test basic strip_hashline functionality.""" - # Create a hashline-formatted text with correct HashPos format: {4-char-hash}::content - text = "abcd::Hello\nefgh::World\nijkl::Test" + # Create a hashline-formatted text with correct HashPos format: {3-char-hash}::content + text = "abc::Hello\nefg::World\nijk::Test" stripped = strip_hashline(text) assert stripped == "Hello\nWorld\nTest" @@ -103,24 +104,24 @@ def test_strip_hashline_with_negative_line_numbers(): """Test strip_hashline with negative line numbers.""" # HashPos format doesn't support negative line numbers in the prefix # Test with standard HashPos format - text = "abcd::Hello\nefgh::World\nijkl::Test" + text = "abc::Hello\nefg::World\nijk::Test" stripped = strip_hashline(text) assert stripped == "Hello\nWorld\nTest" def test_strip_hashline_mixed_lines(): """Test strip_hashline with mixed hashline and non-hashline lines.""" - # HashPos format: {4-char-hash}::content + # HashPos format: {3-char-hash}::content # Plain lines without hashes should be left unchanged - text = "abcd::Hello\nPlain line\nefgh::World" + text = "abc::Hello\nPlain line\nefg::World" stripped = strip_hashline(text) assert stripped == "Hello\nPlain line\nWorld" def test_strip_hashline_preserves_newlines(): """Test that strip_hashline preserves newline characters.""" - # HashPos format: {4-char-hash}::content - text = "abcd::Line 1\nefgh::Line 2\n" + # HashPos format: {3-char-hash}::content + text = "abc::Line 1\nefg::Line 2\n" stripped = strip_hashline(text) # strip_hashline should preserve newlines assert stripped == "Line 1\nLine 2\n" @@ -154,14 +155,14 @@ def test_hashline_different_inputs(): result1 = hashline(text1) result2 = hashline(text2) - # HashPos format: [{4-char-hash}]content + # HashPos format: {3-char-hash}::content # Extract hash from each line (there's only one line for single-line inputs) lines1 = result1.splitlines() lines2 = result2.splitlines() - # Get the hash from each line (format: [hash]content) - hash1 = lines1[0][1:5] if lines1 else "" # Extract 4-char hash - hash2 = lines2[0][1:5] if lines2 else "" # Extract 4-char hash + # Get the hash from each line (format: hash::content) + hash1 = lines1[0][:3] if lines1 else "" # Extract 3-char hash + hash2 = lines2[0][:3] if lines2 else "" # Extract 3-char hash # Hashes should be different (very high probability) assert hash1 != hash2 @@ -169,15 +170,15 @@ def test_hashline_different_inputs(): def test_parse_hashline(): """Test parse_hashline function.""" - # Test basic parsing (HashPos format: {4-char-hash}) - hash_fragment, line_num_str, line_num = parse_hashline("abcd") - assert hash_fragment == "abcd" + # Test basic parsing (HashPos format: {3-char-hash}) + hash_fragment, line_num_str, line_num = parse_hashline("abc") + assert hash_fragment == "abc" assert line_num_str is None # HashPos doesn't include line numbers assert line_num is None # Test with content after hash - hash_fragment, line_num_str, line_num = parse_hashline("efgh::Hello World") - assert hash_fragment == "efgh" + hash_fragment, line_num_str, line_num = parse_hashline("efg::Hello World") + assert hash_fragment == "efg" assert line_num_str is None assert line_num is None From 31bbf227d52bd472ffef16235e2a8aecbed3d844 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Jun 2026 17:34:33 -0400 Subject: [PATCH 08/41] Strip multiple layers of hash fragments from start of strings in edit text --- cecli/tools/edit_text.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 87a21e9c232..01bd273de1d 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -181,12 +181,18 @@ def execute( ) edit_text_raw = edit.get("text") - edit_text = ( - strip_hashline(edit_text_raw) if edit_text_raw is not None else None - ) + edit_text = edit.get("text") edit_start_line = edit.get("start_line") edit_end_line = edit.get("end_line") + if edit_text_raw is not None: + edit_text_raw = strip_hashline(edit_text_raw) + while edit_text_raw != edit_text: + edit_text_raw = strip_hashline(edit_text_raw) + edit_text = strip_hashline(edit_text) + + edit_text = edit_text_raw + # Try to resolve line content values to content IDs # This handles cases where LLMs pass actual line content # instead of content ID markers From 5b0726b8e5f0df8a5991f397ae146c22807a790a Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Jun 2026 17:47:49 -0400 Subject: [PATCH 09/41] Update hashpos messaging in agent modes --- cecli/prompts/agent.yml | 3 ++- cecli/prompts/subagent.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 4143d42d18e..f744a437d14 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -27,7 +27,8 @@ main_system: | ## FILE FORMAT File contents will be prefixed with identifiers. Each line starts with a case-sensitive content ID followed by `::`. These are used to target where editing tools will perform edits. - They are algorithmically generated, maintained, and subject to change. Do not search for these content IDs. Focus on the lines they identify. + They are generated and maintained by a custom algorithm and subject to change on edits. Do not search for these content IDs directly. You will not be able to generate them. + Focus on the lines they identify. **Example File** ``` diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index e10c1935e1d..99b6e7a0fef 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -12,7 +12,8 @@ main_system: | ## FILE FORMAT File contents will be prefixed with identifiers. Each line starts with a case-sensitive content ID followed by `::`. These are used to target where editing tools will perform edits. - They are algorithmically generated, maintained, and subject to change. Do not search for these content IDs. Focus on the lines they identify. + They are generated and maintained by a custom algorithm and subject to change on edits. Do not search for these content IDs directly. You will not be able to generate them. + Focus on the lines they identify. **Example File** ``` From 7cab27a93e9387737759b7721a7326c2819642bc Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Jun 2026 20:37:43 -0400 Subject: [PATCH 10/41] Update tools: - EditText should force all parameters - ReadRange to allow repetition for small range peek outputs - Refine hashpos edit fix ranking for overlap prevention and cummulative change size --- cecli/helpers/hashline.py | 126 ++++++++++++++++++-------------------- cecli/tools/edit_text.py | 8 ++- cecli/tools/read_range.py | 21 ++++--- 3 files changed, 81 insertions(+), 74 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index f03cb071ce8..c1f5a402677 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1463,7 +1463,6 @@ def get_indentation(line: str) -> int: return resolved_ops source_lines = original_content.splitlines() - MAX_STEPS = 3 # Maximum expansion steps for resolved in resolved_ops: op = resolved["op"] @@ -1489,47 +1488,35 @@ def get_indentation(line: str) -> int: llm_end = min(llm_end, len(source_lines) - 1) # --- THE HEALING LOOP --- - # Try original bounds first (distance 0), then progressively expand - # outward in rounds. At each round d>=1, test exactly 4 candidates: - # 1. Both indices down by d lines (range shifts down) - # 2. Both indices up by d lines (range shifts up) - # 3. Start index down by d lines, end unchanged (partial expansion) - # 4. End index down by d lines, start unchanged (partial expansion) - # - # If multiple candidates are valid at a round, select using: - # 1. Longest resulting source code (preserve more code) - # 2. Partial expansions over full range shifts - # 3. Downward changes over upward changes - - found_valid = False - EFFECTIVE_STEPS = MAX_STEPS - - if llm_start == llm_end: - EFFECTIVE_STEPS = 1 - - for distance in range(EFFECTIVE_STEPS + 1): - if distance == 0: - round_candidates = [(0, 0)] - else: - round_candidates = [ - (-distance, 0), # Start down only (partial) - (+distance, 0), # Start up only (partial) - (0, +distance), # End down only (partial) - (0, -distance), # End up only (partial) - (-distance, +distance), # Both indices down - (+distance, -distance), # Both indices up - (+distance, +distance), # Expand outward (start up, end down) - (-distance, -distance), # Contract inward (start down, end up) - ] - - valid_at_round = [] - for start_shift, end_shift in round_candidates: + # Generate all combinations of offsets in {-2, -1, 0, 1, 2} for both + # start and end, then filter to syntactically valid edits and rank + # by cumulative movement (abs(start_shift) + abs(end_shift)) as a + # primary criterion to bias toward minimal range perturbation. + + offsets = [0, 1, -1, 2, -2] + all_candidates = [] + for start_shift in offsets: + for end_shift in offsets: candidate_start = max(0, llm_start - start_shift) candidate_end = min(len(source_lines) - 1, llm_end + end_shift) if candidate_end < candidate_start: continue + # Skip candidates that overlap with other operations' ranges + overlaps = False + for other_op in resolved_ops: + if other_op is resolved: + continue + other_start = other_op["start_idx"] + other_end = other_op["end_idx"] + if candidate_start <= other_end and candidate_end >= other_start: + overlaps = True + break + + if overlaps: + continue + # Skip candidates that would create duplicate adjacent content at edit boundaries if _would_create_duplicate_content( source_lines, candidate_start, candidate_end, repl_lines @@ -1549,6 +1536,9 @@ def get_indentation(line: str) -> int: is_downward = start_shift <= 0 # Negative/zero shift = moving down is_both = start_shift == end_shift # Whole range expansion/contraction closures = count_closures(test_source) + start_line_match = source_lines[candidate_start] == source_lines[llm_start] + end_line_match = source_lines[candidate_end] == source_lines[llm_end] + cumulative_movement = abs(start_shift) + abs(end_shift) # --- INDENTATION SCORING --- indent_score = 0 @@ -1575,45 +1565,48 @@ def get_indentation(line: str) -> int: ) == get_indentation(source_end_line): indent_score += 1 - valid_at_round.append( + all_candidates.append( { "start_idx": candidate_start, "end_idx": candidate_end, + "start_line_match": start_line_match, + "end_line_match": end_line_match, "source_len": len(test_source), "is_partial": is_partial, "is_downward": is_downward, "is_both": is_both, "closure_count": closures, "indent_score": indent_score, + "cumulative_movement": cumulative_movement, + "offsets": (start_shift, end_shift), } ) - if valid_at_round: - # Sort using the new hierarchy: - # 1. Fewest total closures (minimize structural pollution) - # 2. Indentation Score (Descending: 2 is better than 0) - # 3. Longest source (preserve more file content) - # 4. Partial expansions over full range shifts - # 5. Downward changes over upward changes - valid_at_round.sort( - key=lambda r: ( - -r["indent_score"], # Descending: larger score is better (2 > 1 > 0) - -r["source_len"], # Descending: larger is better - r["closure_count"], # Ascending: smaller is better - not r["is_partial"], # Booleans: False comes before True - not r["is_downward"], # Booleans: False comes before True - r["is_both"], # Booleans: False comes before True - ) + if all_candidates: + # Sort using the new hierarchy: + # 1. Cumulative movement (ascending — less movement is better) + # 2. Fewest total closures (minimize structural pollution) + # 3. Indentation Score (Descending: 2 is better than 0) + # 4. Longest source (preserve more file content) + # 5. Partial expansions over full range shifts + # 6. Downward changes over upward changes + all_candidates.sort( + key=lambda r: ( + not r["start_line_match"], # Descending: True first + not r["end_line_match"], # Descending: True first + r["cumulative_movement"], # Ascending: smaller is better + -r["indent_score"], # Descending: larger score is better (2 > 1 > 0) + -r["source_len"], # Descending: larger is better + r["closure_count"], # Ascending: smaller is better + not r["is_partial"], # Booleans: False comes before True + not r["is_downward"], # Booleans: False comes before True + r["is_both"], # Booleans: False comes before True ) + ) - best = valid_at_round[0] - resolved["start_idx"] = best["start_idx"] - resolved["end_idx"] = best["end_idx"] - found_valid = True - break - - if not found_valid: - pass + best = all_candidates[0] + resolved["start_idx"] = best["start_idx"] + resolved["end_idx"] = best["end_idx"] return resolved_ops @@ -1758,6 +1751,12 @@ def apply_hashline_operations( resolved_ops = _merged_contained_ranges(resolved_ops) # Merge contiguous replace operations resolved_ops = _merge_replace_operations(resolved_ops) + + # Sort by start_idx descending to apply from bottom to top + # When operations have same start_idx, apply in order: insert, replace, delete + # This ensures correct behavior when multiple operations target the same line + resolved_ops.sort(key=sort_ranges) + if file_path: # Apply tree-sitter based closure safeguard to snap boundaries to AST nodes resolved_ops = _apply_closure_safeguard(original_content, resolved_ops, file_path) @@ -1766,11 +1765,6 @@ def apply_hashline_operations( source_lines = original_content.splitlines() resolved_ops = _fix_duplicate_content_boundaries(source_lines, resolved_ops) - # Sort by start_idx descending to apply from bottom to top - # When operations have same start_idx, apply in order: insert, replace, delete - # This ensures correct behavior when multiple operations target the same line - resolved_ops.sort(key=sort_ranges) - successful_ops = [] # Loop to apply operations in sorted order (bottom-to-top) for resolved in resolved_ops: diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 01bd273de1d..15dfb18730f 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -88,7 +88,13 @@ class Tool(BaseTool): ), }, }, - "required": ["file_path"], + "required": [ + "file_path", + "operation", + "start_line", + "end_line", + "text", + ], }, "description": "Array of edits to apply.", }, diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index cba2bfd6f29..037c2c88411 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -711,7 +711,7 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, curr if start_found or end_found: if start_found: lines.append( - f"File {rel_path} Snapshot (Lines {start_stub_s + 1} - {start_stub_e + 1}):" + f"File {rel_path} Current Snapshot (Lines {start_stub_s + 1} - {start_stub_e + 1}):" ) lines.extend(hashed_lines[start_stub_s:start_stub_e]) @@ -723,7 +723,7 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, curr ): lines.append("...⋮...") lines.append( - f"File {rel_path} Snapshot (Lines {end_stub_s + 1} - {end_stub_e + 1}):" + f"File {rel_path} Current Snapshot (Lines {end_stub_s + 1} - {end_stub_e + 1}):" ) lines.extend(hashed_lines[end_stub_s:end_stub_e]) @@ -732,14 +732,21 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, curr except Exception: pass - lines = [f"File {rel_path} Snapshot (Lines {s_idx + 1} - {e_idx + 1}):"] + lines = [f"File {rel_path} Current Snapshot (Lines {s_idx + 1} - {e_idx + 1}):"] total = e_idx - s_idx - if total <= 15: + hashed_content = "\n".join(hashed_lines[s_idx : e_idx + 1]) + token_count = coder.main_model.token_count(hashed_content) + + if token_count <= min(coder.large_file_token_threshold / 16, 512): lines.extend(hashed_lines[s_idx : e_idx + 1]) else: - lines.extend(hashed_lines[s_idx : s_idx + 5]) - lines.append("...⋮...") - lines.extend(hashed_lines[e_idx - 4 : e_idx + 1]) + if total <= 15: + lines.extend(hashed_lines[s_idx : e_idx + 1]) + else: + lines.extend(hashed_lines[s_idx : s_idx + 5]) + lines.append("...⋮...") + lines.extend(hashed_lines[e_idx - 4 : e_idx + 1]) + lines.append("") return "\n".join(lines) From 2ac74c8a29ea8353e7d3d8cf145c163a7bf201cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Jun 2026 20:48:26 -0700 Subject: [PATCH 11/41] cli-41: add tests --- cecli/coders/base_coder.py | 5 +- cecli/tools/__init__.py | 4 + cecli/tools/remove_mcp_tool.py | 77 ++++ .../integration/test_agent_mcp_management.py | 137 ++++++++ tests/integration/test_mcp_management.py | 93 +++++ tests/tools/test_remove_mcp_tool.py | 146 ++++++++ ...cp_tool.py => test_tools_load_mcp_tool.py} | 10 +- tests/unit/test_load_mcp_tool.py | 330 +++++++++++++----- tests/unit/test_remove_mcp_tool.py | 196 ++++++++++- tests/unit/test_unit_load_mcp_tool.py | 139 ++++++++ tests/unit/test_unit_remove_mcp_tool.py | 280 +++++++++++++++ 11 files changed, 1298 insertions(+), 119 deletions(-) create mode 100644 cecli/tools/remove_mcp_tool.py create mode 100644 tests/integration/test_agent_mcp_management.py create mode 100644 tests/integration/test_mcp_management.py create mode 100644 tests/tools/test_remove_mcp_tool.py rename tests/tools/{test_load_mcp_tool.py => test_tools_load_mcp_tool.py} (96%) create mode 100644 tests/unit/test_unit_load_mcp_tool.py create mode 100644 tests/unit/test_unit_remove_mcp_tool.py diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 70fc943b2b2..259f7da796c 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -412,9 +412,10 @@ def __init__( security_config=None, uuid: str = "", parent_uuid: str = "", + **kwargs, ): - # initialize from args.map_cache_dir - self.coroutines = coroutines + self.original_kwargs = kwargs + self.original_kwargs = kwargs # Per-instance tool and server filtering dictionaries # Each contains "included" and "excluded" sets that filter from the global singletons self.registered_tools = {"included": set(), "excluded": set()} diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 44e527cff37..4a979b0c123 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -17,9 +17,11 @@ git_show, git_status, grep, + load_mcp_tool, load_skill, ls, read_range, + remove_mcp_tool, remove_skill, thinking, undo_change, @@ -49,4 +51,6 @@ thinking, undo_change, update_todo_list, + load_mcp_tool, + remove_mcp_tool, ] diff --git a/cecli/tools/remove_mcp_tool.py b/cecli/tools/remove_mcp_tool.py new file mode 100644 index 00000000000..7692eba027b --- /dev/null +++ b/cecli/tools/remove_mcp_tool.py @@ -0,0 +1,77 @@ +from typing import List + +from cecli.tools.utils.base_tool import BaseTool + + +class RemoveMcpTool(BaseTool): + NORM_NAME = "remove-mcp" + SCHEMA = { + "type": "function", + "function": { + "name": "remove-mcp", + "description": ( + "Remove MCP server(s) by name, or use '*' to remove all connected servers." + ), + "parameters": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "A list of MCP server names to remove. Use '*' to remove all." + ), + } + }, + "required": ["servers"], + }, + }, + } + + @classmethod + async def execute(cls, coder, servers: List[str]): + """Execute the remove-mcp tool with given parameters.""" + if not coder.mcp_manager or not coder.mcp_manager.servers: + return "No MCP servers are configured." + + results = [] + servers_to_action = [] + + # Determine which servers to act on + if servers == ["*"]: + servers_to_action.extend(coder.mcp_manager.connected_servers.keys()) + else: + for server_name in servers: + server = coder.mcp_manager.get_server(server_name) + if not server: + results.append(f"MCP server {server_name} does not exist.") + elif server.name not in coder.mcp_manager.connected_servers: + results.append(f"Server {server_name} is not currently connected.") + else: + servers_to_action.append(server.name) + + # If there are no servers to act on but we have preliminary results (like errors), return them + if not servers_to_action and results: + return "\n".join(results) + + # If there are no servers to remove at all + if not servers_to_action: + return "No servers to remove." + + # Process the removal + for server_name in servers_to_action: + coder.interrupt_event.clear() + did_disconnect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.disconnect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + results.append(f"Interrupted: {server_name}") + continue + if did_disconnect: + results.append(f"Removed server: {server_name}") + else: + results.append(f"Unable to remove server: {server_name}") + + return "\n".join(results) diff --git a/tests/integration/test_agent_mcp_management.py b/tests/integration/test_agent_mcp_management.py new file mode 100644 index 00000000000..39d20e90101 --- /dev/null +++ b/tests/integration/test_agent_mcp_management.py @@ -0,0 +1,137 @@ +"""Integration tests for agent and subagent MCP management.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from cecli.coders.agent_coder import AgentCoder +from cecli.coders.sub_agent_coder import SubAgentCoder +from cecli.tools.load_mcp_tool import LoadMcpTool +from cecli.tools.remove_mcp_tool import RemoveMcpTool + + +@pytest.fixture +def mock_mcp_manager(): + """Fixture for a mocked McpServerManager.""" + manager = MagicMock() + manager.connected_servers = {} + + # Mock servers + server1 = MagicMock() + server1.name = "test_server" + server1.config = {"enabled": True} + + server2 = MagicMock() + server2.name = "sub_test_server" + server2.config = {"enabled": True} + + manager.servers = [server1, server2] + + def get_server_side_effect(name): + if name == "test_server": + return server1 + if name == "sub_test_server": + return server2 + return None + + manager.get_server.side_effect = get_server_side_effect + + async def connect(server_name): + manager.connected_servers[server_name] = "connected" + return True, False # (did_connect, interrupted) + + async def disconnect(server_name): + if server_name in manager.connected_servers: + del manager.connected_servers[server_name] + return True, False + return False, False + + manager.connect_server = AsyncMock(side_effect=connect) + manager.disconnect_server = AsyncMock(side_effect=disconnect) + manager.add_server = AsyncMock() + manager.add_server = AsyncMock() + manager.add_server = AsyncMock() + manager.add_server = AsyncMock() + + return manager + + +@pytest.fixture +def agent_coder(mock_mcp_manager): + """Fixture for an AgentCoder with a mocked MCP manager.""" + with patch("cecli.coders.agent_coder.McpServerManager", return_value=mock_mcp_manager): + coder = AgentCoder( + main_model=MagicMock(), + io=MagicMock(), + ) + coder.mcp_manager = mock_mcp_manager + coder.original_kwargs = {} + coder.coroutines = Mock() + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible.side_effect = mock_interruptible + coder.interrupt_event = Mock() + return coder + + +@pytest.fixture +async def sub_agent_coder(agent_coder): + """Fixture for a SubAgentCoder.""" + # Fix: Use create() class method instead of direct instantiation + sub_agent = await SubAgentCoder.create(from_coder=agent_coder) + # Ensure sub_agent has the required mocks for tools + sub_agent.coroutines = agent_coder.coroutines + sub_agent.interrupt_event = agent_coder.interrupt_event + return sub_agent + + +@pytest.mark.asyncio +async def test_agent_can_load_mcp_server(agent_coder, mock_mcp_manager): + """Verify an agent can load an MCP server.""" + tool = LoadMcpTool() + server_name = "test_server" + + await tool.execute(agent_coder, servers=[server_name]) + + mock_mcp_manager.connect_server.assert_called_once_with(server_name) + assert server_name in mock_mcp_manager.connected_servers + + +@pytest.mark.asyncio +async def test_agent_can_remove_mcp_server(agent_coder, mock_mcp_manager): + """Verify an agent can remove an MCP server.""" + tool = RemoveMcpTool() + server_name = "test_server" + mock_mcp_manager.connected_servers[server_name] = "connected" + + await tool.execute(agent_coder, servers=[server_name]) + + mock_mcp_manager.disconnect_server.assert_called_once_with(server_name) + assert server_name not in mock_mcp_manager.connected_servers + + +@pytest.mark.asyncio +async def test_sub_agent_can_load_mcp_server(sub_agent_coder, mock_mcp_manager): + """Verify a subagent can load an MCP server.""" + tool = LoadMcpTool() + server_name = "sub_test_server" + + await tool.execute(sub_agent_coder, servers=[server_name]) + + mock_mcp_manager.connect_server.assert_called_once_with(server_name) + assert server_name in mock_mcp_manager.connected_servers + + +@pytest.mark.asyncio +async def test_sub_agent_can_remove_mcp_server(sub_agent_coder, mock_mcp_manager): + """Verify a subagent can remove an MCP server.""" + tool = RemoveMcpTool() + server_name = "sub_test_server" + mock_mcp_manager.connected_servers[server_name] = "connected" + + await tool.execute(sub_agent_coder, servers=[server_name]) + + mock_mcp_manager.disconnect_server.assert_called_once_with(server_name) + assert server_name not in mock_mcp_manager.connected_servers diff --git a/tests/integration/test_mcp_management.py b/tests/integration/test_mcp_management.py new file mode 100644 index 00000000000..0f7f9f38bf1 --- /dev/null +++ b/tests/integration/test_mcp_management.py @@ -0,0 +1,93 @@ +"""Integration tests for MCP management tools.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from cecli.tools.load_mcp_tool import LoadMcpTool +from cecli.tools.remove_mcp_tool import RemoveMcpTool + + +class CoderMock: + """Mock Coder object for integration testing.""" + + def __init__(self): + self.mcp_manager = MagicMock() + self.mcp_manager.servers = [] + self.mcp_manager.connected_servers = {} + self.mcp_manager.get_server.return_value = None + self.mcp_manager.connect_server = AsyncMock(return_value=(True, False)) + self.mcp_manager.disconnect_server = AsyncMock(return_value=(True, False)) + self.coroutines = MagicMock() + self.interrupt_event = MagicMock() + + async def mock_interruptible(self, coro, event): + """Mock interruptible that just executes the coroutine.""" + return await coro, False + + def add_server(self, name, enabled=True): + """Add a mock server to the manager.""" + server = MagicMock() + server.name = name + server.config = {"enabled": enabled} + self.mcp_manager.servers.append(server) + original_get_server = self.mcp_manager.get_server.side_effect + + def get_server_side_effect(server_name): + if server_name == name: + return server + if original_get_server: + return original_get_server(server_name) + return None + + self.mcp_manager.get_server.side_effect = get_server_side_effect + + +@pytest.fixture +def coder(): + """Provide a mock coder for integration testing.""" + return CoderMock() + + +@pytest.mark.asyncio +async def test_integration_load_and_remove_server(coder): + """Test loading and then removing a server.""" + coder.add_server("integration-test-server") + coder.coroutines.interruptible = coder.mock_interruptible + + # Load the server + load_result = await LoadMcpTool.execute(coder, ["integration-test-server"]) + assert "Loaded server: integration-test-server" in load_result + + # Mock the connected server for the remove tool + coder.mcp_manager.connected_servers = {"integration-test-server": coder.mcp_manager.servers[0]} + + # Remove the server + remove_result = await RemoveMcpTool.execute(coder, ["integration-test-server"]) + assert "Removed server: integration-test-server" in remove_result + + +@pytest.mark.asyncio +async def test_integration_wildcard_load_and_remove(coder): + """Test loading and removing all servers with a wildcard.""" + coder.add_server("server1") + coder.add_server("server2") + coder.add_server("server3", enabled=False) + coder.coroutines.interruptible = coder.mock_interruptible + + # Load all enabled servers + load_result = await LoadMcpTool.execute(coder, ["*"]) + assert "Loaded server: server1" in load_result + assert "Loaded server: server2" in load_result + assert "Skipping server (not enabled by default): server3" in load_result + + # Mock the connected servers for the remove tool + coder.mcp_manager.connected_servers = { + "server1": coder.mcp_manager.servers[0], + "server2": coder.mcp_manager.servers[1], + } + + # Remove all connected servers + remove_result = await RemoveMcpTool.execute(coder, ["*"]) + assert "Removed server: server1" in remove_result + assert "Removed server: server2" in remove_result diff --git a/tests/tools/test_remove_mcp_tool.py b/tests/tools/test_remove_mcp_tool.py new file mode 100644 index 00000000000..3d800de5218 --- /dev/null +++ b/tests/tools/test_remove_mcp_tool.py @@ -0,0 +1,146 @@ +"""Unit tests for RemoveMcpTool.execute.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from cecli.tools.remove_mcp_tool import RemoveMcpTool + + +class DummyIO: + """Mock IO object for testing.""" + + def __init__(self): + self.tool_error = Mock() + self.tool_warning = Mock() + self.tool_output = Mock() + self.interrupt_event = Mock() + + +class DummyCoder: + """Mock Coder object for testing.""" + + def __init__(self): + self.io = DummyIO() + self.mcp_manager = Mock() + self.mcp_manager.servers = [] + self.mcp_manager.connected_servers = {} + self.coroutines = Mock() + self.coroutines.interruptible = AsyncMock() + + self.interrupt_event = Mock() + + +@pytest.fixture +def coder(): + """Provide a dummy coder for testing.""" + return DummyCoder() + + +@pytest.fixture +def mock_server(): + """Provide a mock MCP server.""" + server = Mock() + server.name = "test-server" + return server + + +class TestRemoveMcpTool: + """Test cases for RemoveMcpTool.""" + + @pytest.mark.asyncio + async def test_no_configured_servers(self, coder): + """Test when no MCP servers are configured at all.""" + coder.mcp_manager.servers = [] + result = await RemoveMcpTool.execute(coder, servers=["test"]) + assert result == "No MCP servers are configured." + + @pytest.mark.asyncio + async def test_server_not_found(self, coder, mock_server): + """Test when requested server doesn't exist.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {"existing": "server"} + coder.mcp_manager.get_server.return_value = None + result = await RemoveMcpTool.execute(coder, servers=["nonexistent"]) + assert "MCP server nonexistent does not exist." in result + + @pytest.mark.asyncio + async def test_all_servers_not_loaded(self, coder, mock_server): + """Test when multiple servers exist but are not loaded.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server + result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + assert "Server test-server is not currently connected." in result + + @pytest.mark.asyncio + async def test_successful_removal(self, coder, mock_server): + """Test successful server removal.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + coder.coroutines.interruptible.return_value = (True, False) + result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + assert "Removed server: test-server" in result + + @pytest.mark.asyncio + async def test_removal_interrupted(self, coder, mock_server): + """Test when removal is interrupted.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + coder.coroutines.interruptible.return_value = (False, True) + result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + assert "Interrupted: test-server" in result + + @pytest.mark.asyncio + async def test_removal_failed(self, coder, mock_server): + """Test when removal fails.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + coder.coroutines.interruptible.return_value = (False, False) + result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + assert "Unable to remove server: test-server" in result + + @pytest.mark.asyncio + async def test_remove_all_servers(self, coder): + """Test removing all servers with '*' wildcard.""" + server1 = Mock() + server1.name = "server1" + server2 = Mock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + coder.coroutines.interruptible.return_value = (True, False) + result = await RemoveMcpTool.execute(coder, servers=["*"]) + assert "Removed server: server1" in result + assert "Removed server: server2" in result + + @pytest.mark.asyncio + async def test_mixed_results(self, coder): + """Test mixed success/failure results.""" + server1 = Mock() + server1.name = "server1" + server2 = Mock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + call_count = 0 + + async def mock_interruptible_func(*args, **kwargs): + nonlocal call_count + result = (True, False) if call_count == 0 else (False, False) + call_count += 1 + return result + + coder.coroutines.interruptible.side_effect = mock_interruptible_func + result = await RemoveMcpTool.execute(coder, servers=["server1", "server2"]) + assert "Removed server: server1" in result + assert "Unable to remove server: server2" in result diff --git a/tests/tools/test_load_mcp_tool.py b/tests/tools/test_tools_load_mcp_tool.py similarity index 96% rename from tests/tools/test_load_mcp_tool.py rename to tests/tools/test_tools_load_mcp_tool.py index b168c54d270..6c0f5e45cbd 100644 --- a/tests/tools/test_load_mcp_tool.py +++ b/tests/tools/test_tools_load_mcp_tool.py @@ -194,16 +194,16 @@ async def test_wildcard_with_duplicate_iteration_fix(self, coder): coder.mcp_manager.get_server.side_effect = lambda name: next( (s for s in [server1, server2] if s.name == name), None ) + connect_calls = [] - coder.coroutines.interruptible.return_value = (True, False) - - async def mock_interruptible(coro, event): - server_name = coro.cr_frame.f_locals["server_name"] + async def mock_connect_server(server_name): + connect_calls.append(server_name) if server_name == "server2": return True, False return False, False - coder.coroutines.interruptible.side_effect = mock_interruptible + coder.mcp_manager.connect_server.side_effect = mock_connect_server + coder.coroutines.interruptible.side_effect = mock_connect_server result = await LoadMcpTool.execute(coder, servers=["*"]) # Should only attempt to load server2 (server1 should be skipped) diff --git a/tests/unit/test_load_mcp_tool.py b/tests/unit/test_load_mcp_tool.py index 87c8706dbd7..9a0048007f8 100644 --- a/tests/unit/test_load_mcp_tool.py +++ b/tests/unit/test_load_mcp_tool.py @@ -1,4 +1,4 @@ -"""Unit tests for load-mcp tool.""" +"""Unit tests for LoadMcpTool.execute.""" from unittest.mock import AsyncMock, MagicMock @@ -7,128 +7,272 @@ from cecli.tools.load_mcp_tool import LoadMcpTool +class DummyIO: + """Mock IO object for testing.""" + + def __init__(self): + self.tool_error = MagicMock() + self.tool_warning = MagicMock() + self.tool_output = MagicMock() + self.interrupt_event = MagicMock() + + +class DummyCoder: + """Mock Coder object for testing.""" + + def __init__(self): + self.io = DummyIO() + self.mcp_manager = MagicMock() + self.mcp_manager.servers = [] + self.mcp_manager.connected_servers = {} + self.coroutines = MagicMock() + self.interrupt_event = MagicMock() + + @pytest.fixture -def mock_mcp_manager(): - """Fixture for a mocked McpServerManager.""" - manager = MagicMock() - manager.connected_servers = {} +def coder(): + """Provide a dummy coder for testing.""" + return DummyCoder() - # Mock servers - server1 = MagicMock() - server1.name = "test-server" - server1.config = {"enabled": True} - server2 = MagicMock() - server2.name = "server2" - server2.config = {"enabled": True} - server3 = MagicMock() - server3.name = "server3" - server3.config = {"enabled": False} - - manager.servers = [server1, server2, server3] - - def get_server_side_effect(name): - if name == "test-server": - return server1 - if name == "server2": - return server2 - if name == "server3": - return server3 - return None - - manager.get_server.side_effect = get_server_side_effect - - async def connect(server_name): - manager.connected_servers[server_name] = "connected" - return True, False # (did_connect, interrupted) - - async def disconnect(server_name): - if server_name in manager.connected_servers: - del manager.connected_servers[server_name] - return True, False - return False, False - manager.connect_server = AsyncMock(side_effect=connect) - manager.disconnect_server = AsyncMock(side_effect=disconnect) - manager.add_server = AsyncMock() +@pytest.fixture +def mock_server(): + """Provide a mock MCP server.""" + server = MagicMock() + server.name = "test-server" + server.config = {"enabled": True} + return server + + +@pytest.mark.asyncio +async def test_no_mcp_servers_found(coder): + """Test when no MCP servers are configured.""" + coder.mcp_manager.servers = [] + result = await LoadMcpTool.execute(coder, servers=["test"]) + assert result == "No MCP servers found, nothing to load." + - return manager +@pytest.mark.asyncio +async def test_server_not_found(coder, mock_server): + """Test when requested server doesn't exist.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.get_server.return_value = None + result = await LoadMcpTool.execute(coder, servers=["nonexistent"]) + assert "MCP server nonexistent does not exist." in result @pytest.mark.asyncio -async def test_load_mcp_tool_success(mock_mcp_manager): - """Test loading a single MCP server successfully.""" - tool = LoadMcpTool() - # Mock the coder - coder = MagicMock() - coder.mcp_manager = mock_mcp_manager - coder.coroutines = MagicMock() - - # Mock interruptible to return (await coro, False) +async def test_server_already_loaded(coder, mock_server): + """Test when server is already loaded.""" + mock_server.name = "test-server" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + # Set up connect_server as AsyncMock so assert_not_called works + coder.mcp_manager.connect_server = AsyncMock() + + # Mock interruptible to just execute the coroutine async def mock_interruptible(coro, event): return await coro, False - coder.coroutines.interruptible.side_effect = mock_interruptible + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Server already loaded: test-server" in result + # connect_server should not have been called since it was already loaded + coder.mcp_manager.connect_server.assert_not_called() - result = await tool.execute(coder, servers=["test-server"]) - assert "Loaded server: test-server" in result - mock_mcp_manager.connect_server.assert_awaited_once_with("test-server") +@pytest.mark.asyncio +async def test_server_not_enabled_by_default(coder, mock_server): + """Test when server is not enabled by default.""" + mock_server.config = {"enabled": False} + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.get_server.return_value = mock_server + result = await LoadMcpTool.execute(coder, servers=["*"]) + assert "Skipping server (not enabled by default): test-server" in result @pytest.mark.asyncio -async def test_load_mcp_tool_non_existent(mock_mcp_manager): - """Test loading a non-existent MCP server.""" - tool = LoadMcpTool() - coder = MagicMock() - coder.mcp_manager = mock_mcp_manager +async def test_successful_load(coder, mock_server): + """Test successful server loading.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server + + # Set up connect_server as AsyncMock that returns (True, False) + async def mock_connect_server(server_name): + return True, False - result = await tool.execute(coder, servers=["non-existent-server"]) + coder.mcp_manager.connect_server = mock_connect_server - assert "MCP server non-existent-server does not exist." in result - mock_mcp_manager.connect_server.assert_not_awaited() + # Mock interruptible to just execute the coroutine + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Loaded server: test-server" in result @pytest.mark.asyncio -async def test_load_mcp_tool_already_loaded(mock_mcp_manager): - """Test loading an already loaded MCP server.""" - tool = LoadMcpTool() - coder = MagicMock() - coder.mcp_manager = mock_mcp_manager - # Pre-populate connected_servers - server = MagicMock() - server.name = "test-server" - coder.mcp_manager.connected_servers = {"test-server": server} +async def test_load_interrupted(coder, mock_server): + """Test when loading is interrupted.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server - result = await tool.execute(coder, servers=["test-server"]) + # Set up connect_server as AsyncMock + async def mock_connect_server(server_name): + return True, False - assert "Server already loaded: test-server" in result - mock_mcp_manager.connect_server.assert_not_awaited() + coder.mcp_manager.connect_server = mock_connect_server + + # Mock interruptible to return interruption + async def mock_interruptible(coro, event): + return False, True + + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Interrupted: test-server" in result @pytest.mark.asyncio -async def test_load_mcp_tool_wildcard_and_duplicate_fix(mock_mcp_manager): - """Test loading with wildcard and duplicate fix.""" - tool = LoadMcpTool() - coder = MagicMock() - coder.mcp_manager = mock_mcp_manager - coder.coroutines = MagicMock() - - # Mock interruptible to return (await coro, False) +async def test_load_failed(coder, mock_server): + """Test when loading fails.""" + coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.return_value = mock_server + + # Set up connect_server as AsyncMock that returns failure + async def mock_connect_server(server_name): + return False, False + + coder.mcp_manager.connect_server = mock_connect_server + + # Mock interruptible to just execute the coroutine async def mock_interruptible(coro, event): return await coro, False - coder.coroutines.interruptible.side_effect = mock_interruptible + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + assert "Unable to load server: test-server" in result - # Set up connected_servers: server1 is already connected - server1 = mock_mcp_manager.get_server("test-server") - coder.mcp_manager.connected_servers = {"test-server": server1} - result = await tool.execute(coder, servers=["*"]) +@pytest.mark.asyncio +async def test_load_all_servers(coder): + """Test loading all servers with '*' wildcard.""" + server1 = MagicMock() + server1.name = "server1" + server1.config = {"enabled": True} + server2 = MagicMock() + server2.name = "server2" + server2.config = {"enabled": True} + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) - # Check results - assert "Server already loaded: test-server" in result + # Set up connect_server as AsyncMock + async def mock_connect_server(server_name): + return True, False + + coder.mcp_manager.connect_server = mock_connect_server + + # Mock interruptible to just execute the coroutine + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["*"]) + assert "Loaded server: server1" in result assert "Loaded server: server2" in result - assert "Skipping server (not enabled by default): server3" in result - # Verify connect_server was called only once for server2 - mock_mcp_manager.connect_server.assert_awaited_once_with("server2") + +@pytest.mark.asyncio +async def test_mixed_results(coder): + """Test mixed success/failure results.""" + server1 = MagicMock() + server1.name = "server1" + server1.config = {"enabled": True} + server2 = MagicMock() + server2.name = "server2" + server2.config = {"enabled": True} + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + # First call succeeds, second fails + call_count = 0 + + async def mock_connect_server(server_name): + nonlocal call_count + result = True if call_count == 0 else False + call_count += 1 + return result + + coder.mcp_manager.connect_server = mock_connect_server + + # Mock interruptible to just execute the coroutine + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["server1", "server2"]) + assert "Loaded server: server1" in result + assert "Unable to load server: server2" in result + + +@pytest.mark.asyncio +async def test_duplicate_iteration_bug_fix(coder, mock_server): + """Test that duplicate iteration bug is fixed - server already loaded only processed once.""" + mock_server.name = "test-server" + coder.mcp_manager.servers = [mock_server] + # Server already connected + coder.mcp_manager.connected_servers = {"test-server": mock_server} + coder.mcp_manager.get_server.return_value = mock_server + # Set up connect_server as AsyncMock + coder.mcp_manager.connect_server = AsyncMock() + result = await LoadMcpTool.execute(coder, servers=["test-server"]) + # Should only report server already loaded once + assert result.count("Server already loaded: test-server") == 1 + # connect_server should not have been called since it was already loaded + coder.mcp_manager.connect_server.assert_not_called() + + +@pytest.mark.asyncio +async def test_wildcard_with_duplicate_iteration_fix(coder): + """Test wildcard loading with duplicate iteration fix.""" + server1 = MagicMock() + server1.name = "server1" + server1.config = {"enabled": True} + server2 = MagicMock() + server2.name = "server2" + server2.config = {"enabled": True} + coder.mcp_manager.servers = [server1, server2] + # server1 already loaded, server2 not loaded + coder.mcp_manager.connected_servers = {"server1": server1} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + connect_calls = [] + + async def mock_connect_server(server_name): + connect_calls.append(server_name) + if server_name == "server2": + return True, False + return False, False + + coder.mcp_manager.connect_server = mock_connect_server + + # Mock interruptible to just execute the coroutine + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + result = await LoadMcpTool.execute(coder, servers=["*"]) + # Should only attempt to load server2 (server1 should be skipped) + assert "Server already loaded: server1" in result + assert "Loaded server: server2" in result + assert connect_calls == ["server2"] # Only server2 should have been connected diff --git a/tests/unit/test_remove_mcp_tool.py b/tests/unit/test_remove_mcp_tool.py index 39ff5c8275b..27d7e09a7ec 100644 --- a/tests/unit/test_remove_mcp_tool.py +++ b/tests/unit/test_remove_mcp_tool.py @@ -1,10 +1,48 @@ -from unittest.mock import AsyncMock, MagicMock +"""Unit tests for RemoveMcpTool.execute.""" + +from unittest.mock import MagicMock import pytest from cecli.tools.remove_mcp_tool import RemoveMcpTool +class DummyIO: + """Mock IO object for testing.""" + + def __init__(self): + self.tool_error = MagicMock() + self.tool_warning = MagicMock() + self.tool_output = MagicMock() + self.interrupt_event = MagicMock() + + +class DummyCoder: + """Mock Coder object for testing.""" + + def __init__(self): + self.io = DummyIO() + self.mcp_manager = MagicMock() + self.mcp_manager.servers = [] + self.mcp_manager.connected_servers = {} + self.coroutines = MagicMock() + self.interrupt_event = MagicMock() + + +@pytest.fixture +def coder(): + """Provide a dummy coder for testing.""" + return DummyCoder() + + +@pytest.fixture +def mock_server(): + """Provide a mock MCP server.""" + server = MagicMock() + server.name = "test-server" + return server + + @pytest.mark.asyncio async def test_remove_mcp_tool_success(): """Test successful removal of an MCP server.""" @@ -13,23 +51,24 @@ async def test_remove_mcp_tool_success(): coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" - server.config.get.return_value = True # auto_connect enabled coder.mcp_manager.get_server.return_value = server coder.mcp_manager.connected_servers = {"test-server": server} - # Mock disconnect_server as an AsyncMock that returns (True, False) - coder.mcp_manager.disconnect_server = AsyncMock(return_value=(True, False)) - # Mock the interruptible method to execute the coroutine + # Mock disconnect_server as an async function that returns (True, False) + async def mock_disconnect(server_name): + return True, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + # Mock the interruptible method to execute the coroutine directly without interruption async def mock_interruptible(coro, event): return await coro, False coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - # Execute result = await RemoveMcpTool.execute(coder, ["test-server"]) - # Assertions assert "Removed server: test-server" in result coder.mcp_manager.disconnect_server.assert_awaited_once_with("test-server") @@ -44,19 +83,14 @@ async def test_remove_mcp_tool_non_existent(): # Create a mock server that exists (to bypass the 'no servers' check) existing_server = MagicMock() existing_server.name = "existing-server" - existing_server.config.get.return_value = True coder.mcp_manager.servers = [existing_server] # But the one we're looking for doesn't exist coder.mcp_manager.get_server.return_value = None - # Execute result = await RemoveMcpTool.execute(coder, ["non-existent-server"]) - # Assertions assert "MCP server non-existent-server does not exist." in result - assert "MCP server non-existent-server does not exist." in result - @pytest.mark.asyncio async def test_remove_mcp_tool_not_connected(): @@ -68,9 +102,7 @@ async def test_remove_mcp_tool_not_connected(): coder.mcp_manager.servers = [server] coder.mcp_manager.get_server.return_value = server coder.mcp_manager.connected_servers = {} - result = await RemoveMcpTool.execute(coder, ["test-server"]) - assert "Server test-server is not currently connected." in result @@ -79,25 +111,151 @@ async def test_remove_mcp_tool_wildcard(): """Test removing all servers with wildcard '*'.""" coder = MagicMock() coder.mcp_manager = MagicMock() - server1 = MagicMock() server1.name = "server1" server2 = MagicMock() server2.name = "server2" - coder.mcp_manager.servers = [server1, server2] coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} - coder.mcp_manager.disconnect_server = AsyncMock(return_value=True) + # Mock disconnect_server as an async function that returns (True, False) + async def mock_disconnect(server_name): + return True, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + # Mock interruptible to execute the coroutine without interruption async def mock_interruptible(coro, event): return await coro, False coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["*"]) + assert "Removed server: server1" in result + assert "Removed server: server2" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_interrupted(): + """Test when removal is interrupted.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.servers = [server] + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {"test-server": server} + + async def mock_disconnect(server_name): + return False, True + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return False, True + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + result = await RemoveMcpTool.execute(coder, ["test-server"]) + assert "Interrupted: test-server" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_failed(): + """Test when removal fails.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.servers = [server] + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {"test-server": server} + + async def mock_disconnect(server_name): + return False, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + result = await RemoveMcpTool.execute(coder, ["test-server"]) + assert "Unable to remove server: test-server" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_no_servers_configured(): + """Test when no MCP servers are configured at all.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + coder.mcp_manager.servers = [] + result = await RemoveMcpTool.execute(coder, servers=["test"]) + assert result == "No MCP servers are configured." + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_mixed_results(): + """Test mixed success/failure results.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server1 = MagicMock() + server1.name = "server1" + server2 = MagicMock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + call_count = 0 + async def mock_disconnect(server_name): + nonlocal call_count + result = (True, False) if call_count == 0 else (False, False) + call_count += 1 + return result + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + result = await RemoveMcpTool.execute(coder, servers=["server1", "server2"]) + assert "Removed server: server1" in result + assert "Unable to remove server: server2" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_dictionary_iteration_fix(): + """Test that dictionary iteration bug is fixed - iterates over keys correctly.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server1 = MagicMock() + server1.name = "server1" + server2 = MagicMock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + + async def mock_disconnect(server_name): + return True, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + result = await RemoveMcpTool.execute(coder, servers=["*"]) + # Should successfully remove both servers using dictionary keys assert "Removed server: server1" in result assert "Removed server: server2" in result - assert coder.mcp_manager.disconnect_server.await_count == 2 diff --git a/tests/unit/test_unit_load_mcp_tool.py b/tests/unit/test_unit_load_mcp_tool.py new file mode 100644 index 00000000000..34dfaae1f09 --- /dev/null +++ b/tests/unit/test_unit_load_mcp_tool.py @@ -0,0 +1,139 @@ +"""Unit tests for load-mcp tool.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from cecli.tools.load_mcp_tool import LoadMcpTool + + +@pytest.fixture +def mock_mcp_manager(): + """Fixture for a mocked McpServerManager.""" + manager = MagicMock() + manager.connected_servers = {} + + # Mock servers + server1 = MagicMock() + server1.name = "test-server" + server1.config = {"enabled": True} + + server2 = MagicMock() + server2.name = "server2" + server2.config = {"enabled": True} + + server3 = MagicMock() + server3.name = "server3" + server3.config = {"enabled": False} + + manager.servers = [server1, server2, server3] + + def get_server_side_effect(name): + if name == "test-server": + return server1 + if name == "server2": + return server2 + if name == "server3": + return server3 + return None + + manager.get_server.side_effect = get_server_side_effect + + async def connect(server_name): + manager.connected_servers[server_name] = "connected" + return True, False # (did_connect, interrupted) + + async def disconnect(server_name): + if server_name in manager.connected_servers: + del manager.connected_servers[server_name] + return True, False + return False, False + + manager.connect_server = AsyncMock(side_effect=connect) + manager.disconnect_server = AsyncMock(side_effect=disconnect) + manager.add_server = AsyncMock() + return manager + + +@pytest.mark.asyncio +async def test_load_mcp_tool_success(mock_mcp_manager): + """Test loading a single MCP server successfully.""" + tool = LoadMcpTool() + + # Mock the coder + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + + # Mock interruptible to return (await coro, False) + async def mock_interruptible(coro, event): + return await coro + + coder.coroutines = MagicMock() + coder.coroutines.interruptible.side_effect = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await tool.execute(coder, servers=["test-server"]) + + assert "Loaded server: test-server" in result + mock_mcp_manager.connect_server.assert_awaited_once_with("test-server") + + +@pytest.mark.asyncio +async def test_load_mcp_tool_non_existent(mock_mcp_manager): + """Test loading a non-existent MCP server.""" + + tool = LoadMcpTool() + + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + + result = await tool.execute(coder, servers=["non-existent-server"]) + + assert "MCP server non-existent-server does not exist." in result + mock_mcp_manager.connect_server.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_mcp_tool_already_loaded(mock_mcp_manager): + """Test loading an already loaded MCP server.""" + tool = LoadMcpTool() + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + # Pre-populate connected_servers + server = mock_mcp_manager.get_server("test-server") + coder.mcp_manager.connected_servers = {"test-server": server} + + result = await tool.execute(coder, servers=["test-server"]) + + assert "Server already loaded: test-server" in result + mock_mcp_manager.connect_server.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_mcp_tool_wildcard_and_duplicate_fix(mock_mcp_manager): + """Test loading with wildcard and duplicate fix.""" + tool = LoadMcpTool() + coder = MagicMock() + coder.mcp_manager = mock_mcp_manager + + # Mock interruptible to return (await coro, False) + async def mock_interruptible(coro, event): + return await coro + + coder.coroutines = MagicMock() + coder.coroutines.interruptible.side_effect = mock_interruptible + coder.interrupt_event = MagicMock() + + # Set up connected_servers: server1 is already connected + server1 = mock_mcp_manager.get_server("test-server") + coder.mcp_manager.connected_servers = {"test-server": server1} + + result = await tool.execute(coder, servers=["*"]) + + # Check results + assert "Server already loaded: test-server" in result + assert "Loaded server: server2" in result + assert "Skipping server (not enabled by default): server3" in result + + # Verify connect_server was called only once for server2 + mock_mcp_manager.connect_server.assert_awaited_once_with("server2") diff --git a/tests/unit/test_unit_remove_mcp_tool.py b/tests/unit/test_unit_remove_mcp_tool.py new file mode 100644 index 00000000000..0126f1b9a4d --- /dev/null +++ b/tests/unit/test_unit_remove_mcp_tool.py @@ -0,0 +1,280 @@ +"""Unit tests for RemoveMcpTool.execute.""" + +from unittest.mock import MagicMock + +import pytest + +from cecli.tools.remove_mcp_tool import RemoveMcpTool + + +class DummyIO: + """Mock IO object for testing.""" + + def __init__(self): + self.tool_error = MagicMock() + self.tool_warning = MagicMock() + self.tool_output = MagicMock() + self.interrupt_event = MagicMock() + + +class DummyCoder: + """Mock Coder object for testing.""" + + def __init__(self): + self.io = DummyIO() + self.mcp_manager = MagicMock() + self.mcp_manager.servers = [] + self.mcp_manager.connected_servers = {} + self.coroutines = MagicMock() + self.interrupt_event = MagicMock() + + +@pytest.fixture +def coder(): + """Provide a dummy coder for testing.""" + return DummyCoder() + + +@pytest.fixture +def mock_server(): + """Provide a mock MCP server.""" + server = MagicMock() + server.name = "test-server" + return server + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_success(): + """Test successful removal of an MCP server.""" + # Setup + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {"test-server": server} + + # Mock disconnect_server as an AsyncMock that returns (True, False) + async def mock_disconnect(server_name): + return True, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + # Mock the interruptible method to execute the coroutine directly without interruption + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines = MagicMock() + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + # Execute + result = await RemoveMcpTool.execute(coder, ["test-server"]) + + # Assertions + assert "Removed server: test-server" in result + coder.mcp_manager.disconnect_server.assert_awaited_once_with("test-server") + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_non_existent(): + """Test removing a non-existent MCP server.""" + # Setup + coder = MagicMock() + coder.mcp_manager = MagicMock() + # Create a mock server that exists (to bypass the 'no servers' check) + existing_server = MagicMock() + existing_server.name = "existing-server" + coder.mcp_manager.servers = [existing_server] + # But the one we're looking for doesn't exist + coder.mcp_manager.get_server.return_value = None + + # Execute + result = await RemoveMcpTool.execute(coder, ["non-existent-server"]) + + # Assertions + assert "MCP server non-existent-server does not exist." in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_not_connected(): + """Test removing a server that is not connected.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.servers = [server] + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {} + + result = await RemoveMcpTool.execute(coder, ["test-server"]) + + assert "Server test-server is not currently connected." in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_wildcard(): + """Test removing all servers with wildcard '*'.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + + server1 = MagicMock() + server1.name = "server1" + server2 = MagicMock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + + # Mock disconnect_server as an AsyncMock that returns (True, False) + async def mock_disconnect(server_name): + return True, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines = MagicMock() + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await RemoveMcpTool.execute(coder, ["*"]) + + assert "Removed server: server1" in result + assert "Removed server: server2" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_interrupted(): + """Test when removal is interrupted.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.servers = [server] + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {"test-server": server} + + async def mock_disconnect(server_name): + return False, True + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return False, True + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await RemoveMcpTool.execute(coder, ["test-server"]) + + assert "Interrupted: test-server" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_failed(): + """Test when removal fails.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server = MagicMock() + server.name = "test-server" + coder.mcp_manager.servers = [server] + coder.mcp_manager.get_server.return_value = server + coder.mcp_manager.connected_servers = {"test-server": server} + + async def mock_disconnect(server_name): + return False, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await RemoveMcpTool.execute(coder, ["test-server"]) + + assert "Unable to remove server: test-server" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_no_servers_configured(): + """Test when no MCP servers are configured at all.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + coder.mcp_manager.servers = [] + + result = await RemoveMcpTool.execute(coder, servers=["test"]) + + assert result == "No MCP servers are configured." + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_mixed_results(): + """Test mixed success/failure results.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server1 = MagicMock() + server1.name = "server1" + server2 = MagicMock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + + call_count = 0 + + async def mock_disconnect(server_name): + nonlocal call_count + result = (True, False) if call_count == 0 else (False, False) + call_count += 1 + return result + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await RemoveMcpTool.execute(coder, servers=["server1", "server2"]) + + assert "Removed server: server1" in result + assert "Unable to remove server: server2" in result + + +@pytest.mark.asyncio +async def test_remove_mcp_tool_dictionary_iteration_fix(): + """Test that dictionary iteration bug is fixed - iterates over keys correctly.""" + coder = MagicMock() + coder.mcp_manager = MagicMock() + server1 = MagicMock() + server1.name = "server1" + server2 = MagicMock() + server2.name = "server2" + coder.mcp_manager.servers = [server1, server2] + coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) + + async def mock_disconnect(server_name): + return True, False + + coder.mcp_manager.disconnect_server = mock_disconnect + + async def mock_interruptible(coro, event): + return await coro, False + + coder.coroutines.interruptible = mock_interruptible + coder.interrupt_event = MagicMock() + + result = await RemoveMcpTool.execute(coder, servers=["*"]) + + # Should successfully remove both servers using dictionary keys + assert "Removed server: server1" in result + assert "Removed server: server2" in result From 7826ce50447a7c2e9a9bb783fa384a026add4a5d Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 11:48:30 -0700 Subject: [PATCH 12/41] fix: Add missing coroutines attribute and fix status_bar access Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 1 + cecli/tui/app.py | 3 ++- tests/tui/test_app.py | 5 ++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index d57cfad9a9d..b3fe2fef37c 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -108,6 +108,7 @@ def __init__(self, *args, **kwargs): def post_init(self): super().post_init() + self.coroutines = self.io.coroutines # Populate per-instance tool and server filters from config self.registered_tools["included"] = set( map(str.lower, self.agent_config.get("tools_includelist", [])) diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 9d151bff074..f019122e730 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -722,7 +722,8 @@ def update_spinner(self, msg, agent_name: str | None = None): def show_error(self, message, agent_name: str | None = None): """Show an error message in the status bar.""" - self.status_bar.show_notification( + status_bar = self.query_one("#status-bar", StatusBar) + status_bar.show_notification( message, severity="error", timeout=5, agent_name=agent_name ) diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index 5d008b93ad3..768f3cbb4ca 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -197,6 +197,8 @@ def mock_query_one(selector, *args): if isinstance(selector, type): name = selector.__name__ else: + if selector == "#status-bar" or selector == "#status-bar, StatusBar": + return mock_status_bar if "," in selector or "#" in selector: return mock_input_area return mock_footer @@ -217,9 +219,6 @@ def mock_query_one(selector, *args): tui_instance.worker = MagicMock() tui_instance.worker.coder = mock_coder - # Stub status_bar reference - tui_instance.status_bar = mock_status_bar - # Mock AgentService - unknown UUID should return None (no prefix) monkeypatch.setattr( "cecli.helpers.agents.service.AgentService.get_instance", From fcebc490590dbd488fd0629c316297d1de387435 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 12:17:10 -0700 Subject: [PATCH 13/41] fix: Resolve AttributeError in AgentCoder when switching to /agent mode Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index b3fe2fef37c..b3f1943df14 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -32,7 +32,7 @@ from .base_coder import Coder -from cecli.helpers.coroutines import interruptible # isort:skip +from cecli.helpers import coroutines # isort:skip logger = logging.getLogger(__name__) @@ -108,7 +108,7 @@ def __init__(self, *args, **kwargs): def post_init(self): super().post_init() - self.coroutines = self.io.coroutines + self.coroutines = coroutines # Populate per-instance tool and server filters from config self.registered_tools["included"] = set( map(str.lower, self.agent_config.get("tools_includelist", [])) @@ -328,7 +328,9 @@ async def _exec_async(): self.io.tool_warning(f"Executing {tool_name} on {server.name} failed:\nError: {e}") return f"Error executing tool call {tool_name}: {e}" - result, interrupted = await interruptible(_exec_async(), self.interrupt_event) + result, interrupted = await self.coroutines.interruptible( + _exec_async(), self.interrupt_event + ) if interrupted: return "Tool execution interrupted by user." From 0028706c240c7075dfed03b38b68eda4d230f098 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 12:22:58 -0700 Subject: [PATCH 14/41] fix: Use self.coroutines.interruptible in agent_coder.py Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index b3f1943df14..f984bed09b4 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -771,7 +771,7 @@ async def _execute_local_tools(self, tool_calls_list): async def gather_and_await(): return await asyncio.gather(*tasks, return_exceptions=True) - task_results, interrupted = await interruptible( + task_results, interrupted = await self.coroutines.interruptible( gather_and_await(), self.interrupt_event ) From 5534d2cb5fc10895f283c157fb9d8a813cd95095 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 21:33:08 -0400 Subject: [PATCH 15/41] Add real examples from compressed hashpos in system prompts --- cecli/helpers/hashline.py | 8 ++++---- cecli/prompts/agent.yml | 10 +++++----- cecli/prompts/subagent.yml | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index c1f5a402677..dbefdf08e86 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -430,8 +430,8 @@ def get_hashline_diff( original_lines = original_content.splitlines() text_lines = text.splitlines() # Get up to 3 lines of context before (ending at found_end) and after the insertion point - ctx_before = original_lines[max(0, found_end - 2) : found_end + 1] - ctx_after = original_lines[found_end + 1 : min(len(original_lines), found_end + 4)] + ctx_before = original_lines[max(0, found_end - 6) : found_end] + ctx_after = original_lines[found_end + 1 : min(len(original_lines), found_end + 7)] # Build a mini document with context so HashPos computes correct neighborhood hashes mini_lines = ctx_before + text_lines + ctx_after mini_text = "\n".join(mini_lines) @@ -453,8 +453,8 @@ def get_hashline_diff( original_lines = original_content.splitlines() text_lines = text.splitlines() # Get up to 3 lines of context before and after the range - ctx_before = original_lines[max(0, found_start - 3) : found_start] - ctx_after = original_lines[found_end + 1 : min(len(original_lines), found_end + 4)] + ctx_before = original_lines[max(0, found_start - 6) : found_start] + ctx_after = original_lines[found_end + 1 : min(len(original_lines), found_end + 7)] # Build a mini document with context so HashPos computes correct neighborhood hashes mini_lines = ctx_before + text_lines + ctx_after mini_text = "\n".join(mini_lines) diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index f744a437d14..3b2356d94d9 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -32,11 +32,11 @@ main_system: | **Example File** ``` - il9n::#!/usr/bin/env python3 - faoZ:: - uXdn::def example_method(): - WAR5:: return "example" - vwkS:: + 事tN::#!/usr/bin/env python3 + 看XX:: + 法然t::def example_method(): + ä个8:: return "example" + 都ъñ:: ``` ## Core Workflow diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index 99b6e7a0fef..fd6f2754ac7 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -17,11 +17,11 @@ main_system: | **Example File** ``` - il9n::#!/usr/bin/env python3 - faoZ:: - uXdn::def example_method(): - WAR5:: return "example" - vwkS:: + 事tN::#!/usr/bin/env python3 + 看XX:: + 法然t::def example_method(): + ä个8:: return "example" + 都ъñ:: ``` ## Core Workflow From 9e76b89c24d50959dc5604a1610aaaa20f099920 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 19:09:58 -0700 Subject: [PATCH 16/41] refactor: Rename load_mcp_tool and remove_mcp_tool files and classes Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/tools/load_mcp_tool.py | 2 +- cecli/tools/remove_mcp_tool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/tools/load_mcp_tool.py b/cecli/tools/load_mcp_tool.py index c82f467a42b..938b07c8d4b 100644 --- a/cecli/tools/load_mcp_tool.py +++ b/cecli/tools/load_mcp_tool.py @@ -3,7 +3,7 @@ from cecli.tools.utils.base_tool import BaseTool -class LoadMcpTool(BaseTool): +class LoadMcp(BaseTool): NORM_NAME = "load-mcp" SCHEMA = { "type": "function", diff --git a/cecli/tools/remove_mcp_tool.py b/cecli/tools/remove_mcp_tool.py index 7692eba027b..00f2d14cd56 100644 --- a/cecli/tools/remove_mcp_tool.py +++ b/cecli/tools/remove_mcp_tool.py @@ -3,7 +3,7 @@ from cecli.tools.utils.base_tool import BaseTool -class RemoveMcpTool(BaseTool): +class RemoveMcp(BaseTool): NORM_NAME = "remove-mcp" SCHEMA = { "type": "function", From c35ca016fd200d882a986f2c6bd666f07e09f1d9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 19:17:31 -0700 Subject: [PATCH 17/41] refactor: Rename load_mcp and remove_mcp tool files and classes Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/tools/__init__.py | 8 ++++---- cecli/tools/load_mcp_tool.py | 2 +- cecli/tools/remove_mcp_tool.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 4a979b0c123..57a87c906f1 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -17,11 +17,11 @@ git_show, git_status, grep, - load_mcp_tool, + load_mcp, load_skill, ls, read_range, - remove_mcp_tool, + remove_mcp, remove_skill, thinking, undo_change, @@ -51,6 +51,6 @@ thinking, undo_change, update_todo_list, - load_mcp_tool, - remove_mcp_tool, + load_mcp, + remove_mcp, ] diff --git a/cecli/tools/load_mcp_tool.py b/cecli/tools/load_mcp_tool.py index 938b07c8d4b..d66d3c7a40f 100644 --- a/cecli/tools/load_mcp_tool.py +++ b/cecli/tools/load_mcp_tool.py @@ -3,7 +3,7 @@ from cecli.tools.utils.base_tool import BaseTool -class LoadMcp(BaseTool): +class Tool(BaseTool): NORM_NAME = "load-mcp" SCHEMA = { "type": "function", diff --git a/cecli/tools/remove_mcp_tool.py b/cecli/tools/remove_mcp_tool.py index 00f2d14cd56..12e0feeaed2 100644 --- a/cecli/tools/remove_mcp_tool.py +++ b/cecli/tools/remove_mcp_tool.py @@ -3,7 +3,7 @@ from cecli.tools.utils.base_tool import BaseTool -class RemoveMcp(BaseTool): +class Tool(BaseTool): NORM_NAME = "remove-mcp" SCHEMA = { "type": "function", From 2504fc3aefe1da9ed98e2555a76455b7d59ce477 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 20:12:01 -0700 Subject: [PATCH 18/41] refactor: Add mcp_servers context to AgentCoder Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index f984bed09b4..320b491b948 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -183,6 +183,7 @@ def _get_agent_config(self): "todo_list", "sub_agents", "skills", + "mcp_servers", }, ) ) @@ -364,6 +365,7 @@ def _calculate_context_block_tokens(self, force=False): "skills", "sub_agents", "loaded_skills", + "mcp_servers", ] for block_type in block_types: if block_type in self.allowed_context_blocks: @@ -402,6 +404,8 @@ def _generate_context_block(self, block_name): not self.parent_uuid or self.agent_config.get("allow_nested_delegation", False) ): content = self.get_sub_agents_context() + elif block_name == "mcp_servers": + content = self.get_mcp_servers_context() if content is not None: self.context_blocks_cache[block_name] = content return content @@ -1512,6 +1516,51 @@ def get_sub_agents_context(self): self.io.tool_error(f"Error generating sub-agents context: {str(e)}") return None + def get_mcp_servers_context(self): + """ + Generate a context block for available and loaded MCP servers. + + Returns: + Formatted context block string or None if no MCP manager is available. + """ + if not self.use_enhanced_context or not self.mcp_manager: + return None + + try: + result = '\n' + result += "## MCP Servers\n\n" + + all_servers = self.mcp_manager.servers + connected_servers = self.mcp_manager.connected_servers + + loaded_server_names = {server.name for server in connected_servers} + available_servers = [ + server for server in all_servers if server.name not in loaded_server_names + ] + + if loaded_server_names: + result += "### Loaded Servers\n" + result += "The following MCP servers are currently loaded and their tools are available:\n" + for name in sorted(list(loaded_server_names)): + result += f"- {name}\n" + result += "\n" + else: + result += "No MCP servers are currently loaded.\n\n" + + if available_servers: + result += "### Available Servers\n" + result += "The following MCP servers are available to be loaded:\n" + for server in sorted(available_servers, key=lambda s: s.name): + result += f"- {server.name}\n" + result += "\n" + + result += "Use the `LoadMcp` and `RemoveMcp` tools to manage servers.\n" + result += "" + return result + except Exception as e: + self.io.tool_error(f"Error generating MCP servers context: {str(e)}") + return None + def get_child_agent_states(self): """Get the state of all active child sub-agents. From a06b4f90430926f0eb795436258b9b131d7a4a38 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 21:23:03 -0700 Subject: [PATCH 19/41] feat: Add ListMcp tool and command Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 48 ----------------------------------- cecli/commands/list_mcp.py | 50 +++++++++++++++++++++++++++++++++++++ cecli/tools/__init__.py | 1 + cecli/tools/list_mcp.py | 50 +++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 48 deletions(-) create mode 100644 cecli/commands/list_mcp.py create mode 100644 cecli/tools/list_mcp.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 320b491b948..599bdfc7c79 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -183,7 +183,6 @@ def _get_agent_config(self): "todo_list", "sub_agents", "skills", - "mcp_servers", }, ) ) @@ -365,7 +364,6 @@ def _calculate_context_block_tokens(self, force=False): "skills", "sub_agents", "loaded_skills", - "mcp_servers", ] for block_type in block_types: if block_type in self.allowed_context_blocks: @@ -404,8 +402,6 @@ def _generate_context_block(self, block_name): not self.parent_uuid or self.agent_config.get("allow_nested_delegation", False) ): content = self.get_sub_agents_context() - elif block_name == "mcp_servers": - content = self.get_mcp_servers_context() if content is not None: self.context_blocks_cache[block_name] = content return content @@ -1516,50 +1512,6 @@ def get_sub_agents_context(self): self.io.tool_error(f"Error generating sub-agents context: {str(e)}") return None - def get_mcp_servers_context(self): - """ - Generate a context block for available and loaded MCP servers. - - Returns: - Formatted context block string or None if no MCP manager is available. - """ - if not self.use_enhanced_context or not self.mcp_manager: - return None - - try: - result = '\n' - result += "## MCP Servers\n\n" - - all_servers = self.mcp_manager.servers - connected_servers = self.mcp_manager.connected_servers - - loaded_server_names = {server.name for server in connected_servers} - available_servers = [ - server for server in all_servers if server.name not in loaded_server_names - ] - - if loaded_server_names: - result += "### Loaded Servers\n" - result += "The following MCP servers are currently loaded and their tools are available:\n" - for name in sorted(list(loaded_server_names)): - result += f"- {name}\n" - result += "\n" - else: - result += "No MCP servers are currently loaded.\n\n" - - if available_servers: - result += "### Available Servers\n" - result += "The following MCP servers are available to be loaded:\n" - for server in sorted(available_servers, key=lambda s: s.name): - result += f"- {server.name}\n" - result += "\n" - - result += "Use the `LoadMcp` and `RemoveMcp` tools to manage servers.\n" - result += "" - return result - except Exception as e: - self.io.tool_error(f"Error generating MCP servers context: {str(e)}") - return None def get_child_agent_states(self): """Get the state of all active child sub-agents. diff --git a/cecli/commands/list_mcp.py b/cecli/commands/list_mcp.py new file mode 100644 index 00000000000..5342c6c8fb8 --- /dev/null +++ b/cecli/commands/list_mcp.py @@ -0,0 +1,50 @@ +from typing import List + +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result + + +class ListMcpCommand(BaseCommand): + NORM_NAME = "list-mcp" + DESCRIPTION = "List all loaded and available MCP servers." + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the list-mcp command.""" + if not coder.mcp_manager: + return format_command_result(io, cls.NORM_NAME, "MCP manager is not available.") + + all_servers = coder.mcp_manager.servers + connected_servers = coder.mcp_manager.connected_servers + + loaded_server_names = {server.name for server in connected_servers} + available_servers = [ + server for server in all_servers if server.name not in loaded_server_names + ] + + result = [] + if loaded_server_names: + result.append("Loaded MCP Servers:") + for name in sorted(list(loaded_server_names)): + result.append(f"- {name}") + else: + result.append("No MCP servers are currently loaded.") + + result.append("") + + if available_servers: + result.append("Available MCP Servers:") + for server in sorted(available_servers, key=lambda s: s.name): + result.append(f"- {server.name}") + else: + result.append("No other MCP servers are available to load.") + + return format_command_result(io, cls.NORM_NAME, "", "\n".join(result)) + + @classmethod + def get_help(cls) -> str: + """Get help text for the list-mcp command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /list-mcp # Lists all loaded and available MCP servers\n" + return help_text diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 57a87c906f1..b73123d3d7b 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -17,6 +17,7 @@ git_show, git_status, grep, + list_mcp, load_mcp, load_skill, ls, diff --git a/cecli/tools/list_mcp.py b/cecli/tools/list_mcp.py new file mode 100644 index 00000000000..0facedb5a5d --- /dev/null +++ b/cecli/tools/list_mcp.py @@ -0,0 +1,50 @@ +from cecli.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "list-mcp" + SCHEMA = { + "type": "function", + "function": { + "name": "ListMcp", + "description": "List all loaded and available MCP servers.", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + } + + @classmethod + def execute(cls, coder, **kwargs): + """List all loaded and available MCP servers.""" + if not coder.mcp_manager: + return "MCP manager is not available." + + all_servers = coder.mcp_manager.servers + connected_servers = coder.mcp_manager.connected_servers + + loaded_server_names = {server.name for server in connected_servers} + available_servers = [ + server for server in all_servers if server.name not in loaded_server_names + ] + + result = [] + if loaded_server_names: + result.append("Loaded MCP Servers:") + for name in sorted(list(loaded_server_names)): + result.append(f"- {name}") + else: + result.append("No MCP servers are currently loaded.") + + result.append("") + + if available_servers: + result.append("Available MCP Servers:") + for server in sorted(available_servers, key=lambda s: s.name): + result.append(f"- {server.name}") + else: + result.append("No other MCP servers are available to load.") + + return "\n".join(result) From 539058755ad4f6e176f918abac05b111e6813629 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 21:24:55 -0700 Subject: [PATCH 20/41] chore: Remove unused blank line in agent_coder.py Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 599bdfc7c79..f984bed09b4 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1512,7 +1512,6 @@ def get_sub_agents_context(self): self.io.tool_error(f"Error generating sub-agents context: {str(e)}") return None - def get_child_agent_states(self): """Get the state of all active child sub-agents. From c56dc0be41aac5370013b4d28a2142b4dfd56d66 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 02:01:26 -0700 Subject: [PATCH 21/41] fix --- cecli/commands/remove_mcp.py | 2 +- cecli/tools/__init__.py | 5 +++-- cecli/tools/{load_mcp_tool.py => load_mcp.py} | 2 +- cecli/tools/{remove_mcp_tool.py => remove_mcp.py} | 4 ++-- cecli/tui/app.py | 4 +--- tests/unit/{test_load_mcp_tool.py => test_load_mcp.py} | 0 tests/unit/{test_remove_mcp_tool.py => test_remove_mcp.py} | 0 7 files changed, 8 insertions(+), 9 deletions(-) rename cecli/tools/{load_mcp_tool.py => load_mcp.py} (98%) rename cecli/tools/{remove_mcp_tool.py => remove_mcp.py} (95%) rename tests/unit/{test_load_mcp_tool.py => test_load_mcp.py} (100%) rename tests/unit/{test_remove_mcp_tool.py => test_remove_mcp.py} (100%) diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index b3432a97ca0..1f04bc05cac 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -6,7 +6,7 @@ class RemoveMcpCommand(BaseCommand): NORM_NAME = "remove-mcp" - DESCRIPTION = "Remove a MCP server by name, or use '*' to remove all" + DESCRIPTION = "Remove (unload) a MCP server by name, or use '*' to remove all" show_completion_notification = False @classmethod diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index b73123d3d7b..54dd6622227 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -45,13 +45,14 @@ git_show, git_status, grep, + list_mcp, + load_mcp, load_skill, ls, read_range, + remove_mcp, remove_skill, thinking, undo_change, update_todo_list, - load_mcp, - remove_mcp, ] diff --git a/cecli/tools/load_mcp_tool.py b/cecli/tools/load_mcp.py similarity index 98% rename from cecli/tools/load_mcp_tool.py rename to cecli/tools/load_mcp.py index d66d3c7a40f..fb98f55811d 100644 --- a/cecli/tools/load_mcp_tool.py +++ b/cecli/tools/load_mcp.py @@ -8,7 +8,7 @@ class Tool(BaseTool): SCHEMA = { "type": "function", "function": { - "name": "load-mcp", + "name": "LoadMCP", "description": "Load MCP server(s) by name, or use '*' to load all enabled servers.", "parameters": { "type": "object", diff --git a/cecli/tools/remove_mcp_tool.py b/cecli/tools/remove_mcp.py similarity index 95% rename from cecli/tools/remove_mcp_tool.py rename to cecli/tools/remove_mcp.py index 12e0feeaed2..788647de8c6 100644 --- a/cecli/tools/remove_mcp_tool.py +++ b/cecli/tools/remove_mcp.py @@ -8,9 +8,9 @@ class Tool(BaseTool): SCHEMA = { "type": "function", "function": { - "name": "remove-mcp", + "name": "RemoveMCP", "description": ( - "Remove MCP server(s) by name, or use '*' to remove all connected servers." + "Remove (unload) MCP server(s) by name, or use '*' to remove all connected servers." ), "parameters": { "type": "object", diff --git a/cecli/tui/app.py b/cecli/tui/app.py index f019122e730..b54b498fc0d 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -723,9 +723,7 @@ def update_spinner(self, msg, agent_name: str | None = None): def show_error(self, message, agent_name: str | None = None): """Show an error message in the status bar.""" status_bar = self.query_one("#status-bar", StatusBar) - status_bar.show_notification( - message, severity="error", timeout=5, agent_name=agent_name - ) + status_bar.show_notification(message, severity="error", timeout=5, agent_name=agent_name) def on_resize(self) -> None: file_list = self.query_one("#file-list", FileList) diff --git a/tests/unit/test_load_mcp_tool.py b/tests/unit/test_load_mcp.py similarity index 100% rename from tests/unit/test_load_mcp_tool.py rename to tests/unit/test_load_mcp.py diff --git a/tests/unit/test_remove_mcp_tool.py b/tests/unit/test_remove_mcp.py similarity index 100% rename from tests/unit/test_remove_mcp_tool.py rename to tests/unit/test_remove_mcp.py From 23a1955eb735f064caf161c8ecc4fe69da542dc2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 10:23:46 -0400 Subject: [PATCH 22/41] Don't load coder class definitions into memory unless actually insantiated --- cecli/coders/__init__.py | 93 +++++++++++++++++++-------------- cecli/coders/base_coder.py | 35 ++++++++++--- cecli/coders/copypaste_coder.py | 15 +++--- 3 files changed, 88 insertions(+), 55 deletions(-) diff --git a/cecli/coders/__init__.py b/cecli/coders/__init__.py index 3fe9c0e3373..e5693180774 100644 --- a/cecli/coders/__init__.py +++ b/cecli/coders/__init__.py @@ -1,42 +1,57 @@ -from .agent_coder import AgentCoder -from .architect_coder import ArchitectCoder -from .ask_coder import AskCoder -from .base_coder import Coder -from .context_coder import ContextCoder -from .copypaste_coder import CopyPasteCoder -from .editblock_coder import EditBlockCoder -from .editblock_fenced_coder import EditBlockFencedCoder -from .editor_diff_fenced_coder import EditorDiffFencedCoder -from .editor_editblock_coder import EditorEditBlockCoder -from .editor_whole_coder import EditorWholeFileCoder -from .hashline_coder import HashLineCoder -from .help_coder import HelpCoder -from .patch_coder import PatchCoder -from .sub_agent_coder import SubAgentCoder -from .udiff_coder import UnifiedDiffCoder -from .udiff_simple import UnifiedDiffSimpleCoder -from .wholefile_coder import WholeFileCoder - -# from .single_wholefile_func_coder import SingleWholeFileFunctionCoder +"""Coder module with lazy imports to reduce startup memory.""" __all__ = [ - HelpCoder, - AskCoder, - Coder, - EditBlockCoder, - EditBlockFencedCoder, - WholeFileCoder, - PatchCoder, - UnifiedDiffCoder, - UnifiedDiffSimpleCoder, - # SingleWholeFileFunctionCoder, - ArchitectCoder, - EditorEditBlockCoder, - EditorWholeFileCoder, - EditorDiffFencedCoder, - ContextCoder, - AgentCoder, - CopyPasteCoder, - HashLineCoder, - SubAgentCoder, + "HelpCoder", + "AskCoder", + "Coder", + "EditBlockCoder", + "EditBlockFencedCoder", + "WholeFileCoder", + "PatchCoder", + "UnifiedDiffCoder", + "UnifiedDiffSimpleCoder", + "ArchitectCoder", + "EditorEditBlockCoder", + "EditorWholeFileCoder", + "EditorDiffFencedCoder", + "ContextCoder", + "AgentCoder", + "CopyPasteCoder", + "HashLineCoder", + "SubAgentCoder", ] + +# Module name mapping (snake_case to class name) +_MODULE_MAP = { + "HelpCoder": ".help_coder", + "AskCoder": ".ask_coder", + "Coder": ".base_coder", + "EditBlockCoder": ".editblock_coder", + "EditBlockFencedCoder": ".editblock_fenced_coder", + "WholeFileCoder": ".wholefile_coder", + "PatchCoder": ".patch_coder", + "UnifiedDiffCoder": ".udiff_coder", + "UnifiedDiffSimpleCoder": ".udiff_simple", + "ArchitectCoder": ".architect_coder", + "EditorEditBlockCoder": ".editor_editblock_coder", + "EditorWholeFileCoder": ".editor_whole_coder", + "EditorDiffFencedCoder": ".editor_diff_fenced_coder", + "ContextCoder": ".context_coder", + "AgentCoder": ".agent_coder", + "CopyPasteCoder": ".copypaste_coder", + "HashLineCoder": ".hashline_coder", + "SubAgentCoder": ".sub_agent_coder", +} + + +def __getattr__(name): + if name in _MODULE_MAP: + import importlib + + mod = importlib.import_module(_MODULE_MAP[name], __package__) + return getattr(mod, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return __all__ diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 1738092fb6e..672a0d1a66a 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -111,6 +111,28 @@ def wrap_fence(name): wrap_fence("sourcecode"), ] +# Map of edit_format values to coder class names. +# Used by Coder.create() to find the right coder class by edit_format +# without importing all coder modules. +EDIT_FORMAT_MAP = { + "help": "HelpCoder", + "ask": "AskCoder", + "diff": "EditBlockCoder", + "diff-fenced": "EditBlockFencedCoder", + "whole": "WholeFileCoder", + "patch": "PatchCoder", + "udiff": "UnifiedDiffCoder", + "udiff-simple": "UnifiedDiffSimpleCoder", + "architect": "ArchitectCoder", + "editor-diff": "EditorEditBlockCoder", + "editor-whole": "EditorWholeFileCoder", + "editor-diff-fenced": "EditorDiffFencedCoder", + "context": "ContextCoder", + "agent": "AgentCoder", + "hashline": "HashLineCoder", + "subagent": "SubAgentCoder", +} + class UsageMeta(type): """Metaclass that provides shared accumulator properties across all Coder subclasses. @@ -335,9 +357,10 @@ async def create( res = coders.CopyPasteCoder(main_model, io, args=args, **kwargs) if not res: - for coder in coders.__all__: - if hasattr(coder, "edit_format") and coder.edit_format == edit_format: - res = coder(main_model, io, args=args, **kwargs) + coder_name = EDIT_FORMAT_MAP.get(edit_format) + if coder_name: + coder_cls = getattr(coders, coder_name) + res = coder_cls(main_model, io, args=args, **kwargs) if res is not None: if from_coder: @@ -359,11 +382,7 @@ async def create( res.original_kwargs = dict(kwargs) return res - valid_formats = [ - str(c.edit_format) - for c in coders.__all__ - if hasattr(c, "edit_format") and c.edit_format is not None - ] + valid_formats = list(EDIT_FORMAT_MAP.keys()) raise UnknownEditFormat(edit_format, valid_formats) async def clone(self, **kwargs): diff --git a/cecli/coders/copypaste_coder.py b/cecli/coders/copypaste_coder.py index 65dd32d5a1f..ec92f522e4b 100644 --- a/cecli/coders/copypaste_coder.py +++ b/cecli/coders/copypaste_coder.py @@ -78,14 +78,13 @@ def _init_prompts_from_selected_edit_format(self): coders = None target_coder_class = None - if coders is not None: - for coder_cls in getattr(coders, "__all__", []): - if ( - hasattr(coder_cls, "edit_format") - and coder_cls.edit_format == selected_edit_format - ): - target_coder_class = coder_cls - break + if coders is not None and selected_edit_format: + # Use EDIT_FORMAT_MAP from base_coder to find the right coder class by edit_format + from cecli.coders.base_coder import EDIT_FORMAT_MAP + + coder_name = EDIT_FORMAT_MAP.get(selected_edit_format) + if coder_name: + target_coder_class = getattr(coders, coder_name, None) # Mirror prompt pack + edit_format where available. if target_coder_class is not None: From b5661259a9b6be66c6453ee6e439f8fc09df1b1a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 10:29:05 -0400 Subject: [PATCH 23/41] Parse models out of json instead of deserializing entire large metadata files --- cecli/models.py | 59 ++++++++++++++++++++++---- tests/basic/test_model_info_manager.py | 2 +- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index 190757a4dc4..3f593cb7b75 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -148,6 +148,7 @@ def __init__(self): self.cache_dir = handle_core_files(Path.home() / ".cecli" / "caches") self.cache_file = self.cache_dir / "model_prices_and_context_window.json" self.content = None + self._raw_content = None self.local_model_metadata = {} self.verify_ssl = True self._cache_loaded = False @@ -167,9 +168,9 @@ def _load_cache(self): cache_age = time.time() - self.cache_file.stat().st_mtime if cache_age < self.CACHE_TTL: try: - self.content = json.loads(self.cache_file.read_text()) + self._raw_content = self.cache_file.read_text() except json.JSONDecodeError: - self.content = None + self._raw_content = None except OSError: pass self._cache_loaded = True @@ -180,9 +181,13 @@ def _update_cache(self): response = requests.get(self.MODEL_INFO_URL, timeout=5, verify=self.verify_ssl) if response.status_code == 200: - self.content = response.json() + # Use json.dumps(response.json()) instead of response.text for + # compatibility with mocked responses in tests + parsed = response.json() + self._raw_content = json.dumps(parsed) try: - self.cache_file.write_text(json.dumps(self.content, indent=4)) + parsed = response.json() + self.cache_file.write_text(json.dumps(parsed, indent=4)) except OSError: pass except Exception as ex: @@ -192,21 +197,59 @@ def _update_cache(self): except OSError: pass + def _get_entry_from_raw(self, key): + """Parse a single model entry from raw JSON string without loading the entire dict.""" + if not self._raw_content: + return None + import re + + escaped_key = re.escape(key) + pattern = rf'(? 0: + ch = self._raw_content[pos] + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = not in_string + elif not in_string: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + pos += 1 + if depth == 0: + entry_str = self._raw_content[start:pos] + return json.loads(entry_str) + return None + def get_model_from_cached_json_db(self, model): data = self.local_model_metadata.get(model) if data: return data self._load_cache() - if not self.content: + if not self._raw_content: self._update_cache() - if not self.content: + if not self._raw_content: return dict() - info = self.content.get(model, dict()) + info = self._get_entry_from_raw(model) if info: return info pieces = model.split("/") if len(pieces) == 2: - info = self.content.get(pieces[1]) + info = self._get_entry_from_raw(pieces[1]) if info and info.get("litellm_provider") == pieces[0]: return info return dict() diff --git a/tests/basic/test_model_info_manager.py b/tests/basic/test_model_info_manager.py index 8c62719d1a3..f7c802d298d 100644 --- a/tests/basic/test_model_info_manager.py +++ b/tests/basic/test_model_info_manager.py @@ -54,7 +54,7 @@ def test_lazy_loading_cache(self): # Verify cache was loaded self.assertTrue(self.manager._cache_loaded) - self.assertIsNotNone(self.manager.content) + self.assertIsNotNone(self.manager._raw_content) self.assertEqual(result, {"max_tokens": 4096}) # Verify _update_cache was not called since cache exists and is valid From e992feb808b0964e73c9645adcda7366e38b3ae7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 10:53:43 -0400 Subject: [PATCH 24/41] Defer large imports of tree-sitter to improve start up time --- cecli/helpers/hashline.py | 5 +++-- cecli/linter.py | 9 +++++++-- cecli/repomap.py | 33 ++++++++++++++++++--------------- cecli/watch.py | 2 +- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index dbefdf08e86..c6904ef3d23 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1,8 +1,6 @@ import difflib import re -from cecli.helpers.grep_ast.parsers import filename_to_lang -from cecli.helpers.grep_ast.tsl import get_language, get_parser from cecli.helpers.hashpos.hashpos import HashPos HASHLINE_PREFIX_RE = HashPos.HASH_PREFIX_RE @@ -1450,6 +1448,9 @@ def get_indentation(line: str) -> int: return resolved_ops # Determine language from file path + from cecli.helpers.grep_ast.parsers import filename_to_lang + from cecli.helpers.grep_ast.tsl import get_language, get_parser + lang = filename_to_lang(file_path) if not lang: return resolved_ops diff --git a/cecli/linter.py b/cecli/linter.py index 434724e2bdf..45003c6b01d 100644 --- a/cecli/linter.py +++ b/cecli/linter.py @@ -10,8 +10,6 @@ import oslex from cecli.dump import dump # noqa: F401 -from cecli.helpers.grep_ast import TreeContext, filename_to_lang -from cecli.helpers.grep_ast.tsl import get_parser # noqa: E402 from cecli.helpers.threading import ThreadSafeEvent from cecli.run_cmd import run_cmd_async, run_cmd_subprocess # noqa: F401 @@ -80,6 +78,8 @@ def errors_to_lint_result(self, rel_fname, errors): return LintResult(text=errors, lines=linenums) async def lint(self, fname, cmd=None): + from cecli.helpers.grep_ast import filename_to_lang + rel_fname = self.get_rel_fname(fname) try: code = Path(fname).read_text(encoding=self.encoding, errors="replace") @@ -204,6 +204,9 @@ def basic_lint(fname, code): Use tree-sitter to look for syntax errors, display them with tree context. """ + from cecli.helpers.grep_ast import filename_to_lang + from cecli.helpers.grep_ast.tsl import get_parser + lang = filename_to_lang(fname) if not lang: return @@ -233,6 +236,8 @@ def basic_lint(fname, code): def tree_context(fname, code, line_nums): + from cecli.helpers.grep_ast import TreeContext + context = TreeContext( fname, code, diff --git a/cecli/repomap.py b/cecli/repomap.py index 1f721ef0ca3..dbb9739d5c3 100644 --- a/cecli/repomap.py +++ b/cecli/repomap.py @@ -10,10 +10,7 @@ from importlib import resources from pathlib import Path -import tree_sitter from diskcache import Cache -from pygments.lexers import guess_lexer_for_filename -from pygments.token import Token from cecli.dump import dump from cecli.helpers.similarity import ( @@ -22,18 +19,9 @@ normalize_vector, ) from cecli.special import filter_important_files -from cecli.tools.utils.helpers import ToolError -# tree_sitter is throwing a FutureWarning warnings.simplefilter("ignore", category=FutureWarning) -from cecli.helpers.grep_ast import TreeContext, filename_to_lang # noqa: E402 -from cecli.helpers.grep_ast.tsl import ( # noqa: E402 - USING_TSL_PACK, - get_language, - get_parser, -) - # Define the Tag namedtuple with a default for specific_kind to maintain compatibility # with cached entries that might have been created with the old definition @@ -117,9 +105,11 @@ def __new__( SQLITE_ERRORS = (sqlite3.OperationalError, sqlite3.DatabaseError, OSError) -CACHE_VERSION = 7 -if USING_TSL_PACK: - CACHE_VERSION = 9 +# CACHE_VERSION determines tags cache format. +# Set to 9 to ensure fresh cache when tree-sitter features are available. +# tree_sitter is loaded lazily inside get_tags_raw(), so we don't +# import it at module level here. +CACHE_VERSION = 9 UPDATING_REPO_MAP_MESSAGE = "Updating repo map" @@ -517,6 +507,8 @@ def get_symbol_definition_location(self, file_path, symbol_name): Raises: ToolError: If the symbol is not found, not unique, or not a definition. """ + from cecli.tools.utils.helpers import ToolError + abs_path = self.io.root_abs_path(file_path) # Assuming io has this helper or similar rel_path = self.get_rel_fname(abs_path) # Ensure we use consistent relative path @@ -605,6 +597,13 @@ def check_import_match(self, definer, imports): return False def get_tags_raw(self, fname, rel_fname): + import tree_sitter + from pygments.lexers import guess_lexer_for_filename + from pygments.token import Token + + from cecli.helpers.grep_ast import filename_to_lang + from cecli.helpers.grep_ast.tsl import USING_TSL_PACK, get_language, get_parser + lang = filename_to_lang(fname) if not lang: return @@ -1279,6 +1278,8 @@ def get_ranked_tags_map_uncached( def render_tree( self, abs_fname, rel_fname, lois, line_numbers=False, start_line=None, end_line=None ): + from cecli.helpers.grep_ast import TreeContext + mtime = self.get_mtime(abs_fname) key = (rel_fname, tuple(sorted(lois)), mtime, start_line, end_line) @@ -1444,6 +1445,8 @@ def find_src_files(directory): def get_scm_fname(lang): + from cecli.helpers.grep_ast.tsl import USING_TSL_PACK + # Load the tags queries if USING_TSL_PACK: subdir = "tree-sitter-language-pack" diff --git a/cecli/watch.py b/cecli/watch.py index f2c77ccbf96..e4c508cd8a9 100644 --- a/cecli/watch.py +++ b/cecli/watch.py @@ -7,7 +7,6 @@ from pathspec.patterns import GitWildMatchPattern from cecli.dump import dump # noqa -from cecli.helpers.grep_ast import TreeContext from cecli.watch_prompts import watch_ask_prompt, watch_code_prompt @@ -185,6 +184,7 @@ def stop(self): def process_changes(self): """Get any detected file changes""" + from cecli.helpers.grep_ast import TreeContext has_action = None added = False From 064d3fc9932306b80f2eca488390bf2f82e1b6b2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 11:33:39 -0400 Subject: [PATCH 25/41] #583: Add secondary command confirmation for allowing command for the session --- cecli/tools/command.py | 39 ++++++++++++-- cecli/tools/command_interactive.py | 86 +++++++++++++++++++++--------- 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index f09d32f2cdf..bf154004da0 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -3,6 +3,8 @@ import os import platform +import xxhash + from cecli.helpers.background_commands import BackgroundCommandManager from cecli.run_cmd import run_cmd_subprocess from cecli.tools.utils.base_tool import BaseTool @@ -14,6 +16,7 @@ class Tool(BaseTool): NORM_NAME = "command" TRACK_INVOCATIONS = False + ALLOWED_SESSION_COMMANDS = {} SCHEMA = { "type": "function", "function": { @@ -70,6 +73,14 @@ def _parse_command_key(command): return command.split("::", 1)[1].strip() return None + @staticmethod + def _hash_command(command): + """Compute an xxhash of the full command text for session tracking.""" + if not command: + return command + + return xxhash.xxh64(command).hexdigest() + @classmethod async def execute( cls, coder, command, background=False, stop=None, stdin=None, pty=False, **kwargs @@ -124,6 +135,8 @@ async def execute( if not confirmed: return "Command execution skipped by user." + command = coder.format_command_with_prefix(command) + # Determine timeout from agent_config (default: 30 seconds) timeout = 0 if hasattr(coder, "agent_config"): @@ -139,6 +152,15 @@ async def execute( @classmethod async def _get_confirmation(cls, coder, command_string, background): """Get user confirmation for command execution.""" + # Hash command for dict key lookup + command_hash = cls._hash_command(command_string) + + # Check if command is already handled for this session + if command_hash in cls.ALLOWED_SESSION_COMMANDS: + if cls.ALLOWED_SESSION_COMMANDS[command_hash]: + return True # Previously approved for session + # Previously declined - skip session question, continue to normal confirmation + if coder.skip_cli_confirmations: return True @@ -150,21 +172,32 @@ async def _get_confirmation(cls, coder, command_string, background): if fnmatch.fnmatch(command_string, pattern): return True - command_string = coder.format_command_with_prefix(command_string) + formatted_command = coder.format_command_with_prefix(command_string) if background: prompt = "Allow execution of this background command?" else: prompt = "Allow execution of this command?" - return await coder.io.confirm_ask( + confirmed = await coder.io.confirm_ask( prompt, - subject=command_string, + subject=formatted_command, explicit_yes_required=True, allow_never=True, group_response="Command Tool", ) + if confirmed: + # Ask if user wants to allow for the entire session (only once per command) + if command_hash not in cls.ALLOWED_SESSION_COMMANDS: + session_allowed = await coder.io.confirm_ask( + "Allow this command for the rest of the session?", + subject=formatted_command, + ) + cls.ALLOWED_SESSION_COMMANDS[command_hash] = session_allowed + + return confirmed + @classmethod async def _execute_background(cls, coder, command_string, use_pty=False): """ diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 30a27a60ec8..1a24a66093d 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -2,6 +2,8 @@ import asyncio import fnmatch +import xxhash + from cecli.run_cmd import run_cmd from cecli.tools.utils.base_tool import BaseTool @@ -9,6 +11,7 @@ class Tool(BaseTool): NORM_NAME = "commandinteractive" TRACK_INVOCATIONS = False + ALLOWED_SESSION_COMMANDS = {} SCHEMA = { "type": "function", "function": { @@ -36,40 +39,74 @@ def _is_command_allowed(coder, command_string): """Check if command matches any allowed_commands patterns.""" if hasattr(coder, "agent_config"): allowed_commands = coder.agent_config.get("allowed_commands", []) - for pattern in allowed_commands: - if fnmatch.fnmatch(command_string, pattern): - return True - + if allowed_commands: + for pattern in allowed_commands: + if fnmatch.fnmatch(command_string, pattern): + return True return False + @staticmethod + def _hash_command(command): + """Compute an xxhash of the full command text for session tracking.""" + if not command: + return command + + return xxhash.xxh64(command).hexdigest() + + @classmethod + async def _get_confirmation(cls, coder, command_string): + """Get user confirmation for command execution.""" + # Hash command for dict key lookup + command_hash = cls._hash_command(command_string) + + # Check if command is already handled for this session + if command_hash in cls.ALLOWED_SESSION_COMMANDS: + if cls.ALLOWED_SESSION_COMMANDS[command_hash]: + return True # Previously approved for session + # Previously declined - skip session question, continue to normal confirmation + + if coder.skip_cli_confirmations: + return True + + # Check if command matches any allowed_commands patterns + if cls._is_command_allowed(coder, command_string): + return True + + formatted_command = coder.format_command_with_prefix(command_string) + + confirmed = await coder.io.confirm_ask( + "Allow execution of this command?", + subject=formatted_command, + explicit_yes_required=True, + allow_never=True, + group_response="Command Interactive Tool", + ) + + if not confirmed: + return False + + # Ask if user wants to allow for the entire session (only once per command) + if command_hash not in cls.ALLOWED_SESSION_COMMANDS: + session_allowed = await coder.io.confirm_ask( + "Allow this command for the rest of the session?", + subject=formatted_command, + ) + cls.ALLOWED_SESSION_COMMANDS[command_hash] = session_allowed + + return True + @classmethod async def execute(cls, coder, command_string, **kwargs): """ Execute an interactive shell command using run_cmd (which uses pexpect/PTY). """ try: - command_string = coder.format_command_with_prefix(command_string) - - confirmed = ( - True - if coder.skip_cli_confirmations or cls._is_command_allowed(coder, command_string) - else await coder.io.confirm_ask( - "Allow execution of this command?", - subject=command_string, - explicit_yes_required=True, # Require explicit 'yes' or 'always' - allow_never=True, # Enable the 'Always' option - group_response="Command Interactive Tool", - ) - ) - + confirmed = await cls._get_confirmation(coder, command_string) if not confirmed: - # This happens if the user explicitly says 'no' this time. - # If 'Always' was chosen previously, confirm_ask returns True directly. - coder.io.tool_output( - f"Skipped execution of shell command: {command_string}", type="tool-result" - ) return "Shell command execution skipped by user." + command_string = coder.format_command_with_prefix(command_string) + coder.io.tool_output( f"⛭ Starting interactive shell command: {command_string}", type="tool-result" ) @@ -86,7 +123,6 @@ def _run_interactive(): ) if tui: - # Notify user and suspend TUI for interactive command coder.io.tool_output( ">>> Suspending TUI for interactive command <<<", type="tool-result" ) @@ -107,10 +143,8 @@ def _run_interactive(): # Format the output for the result message, include more content output_content = combined_output or "" - # Use the existing token threshold constant as the character limit for truncation output_limit = coder.large_file_token_threshold if coder.context_management_enabled and len(output_content) > output_limit: - # Truncate and add a clear message using the constant value output_content = ( output_content[:output_limit] + f"\n... (output truncated at {output_limit} characters, based on" From 70eba9f749e43399151c950bacedd756f0d44a8a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 11:40:52 -0400 Subject: [PATCH 26/41] Address frequent pandoc failures in CI/CD runs --- cecli/scrape.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cecli/scrape.py b/cecli/scrape.py index 0722e11768d..390e3e9b295 100755 --- a/cecli/scrape.py +++ b/cecli/scrape.py @@ -220,7 +220,7 @@ def scrape_with_httpx(self, url): return None, None def try_pandoc(self): - if self.pandoc_available: + if self.pandoc_available is not None: return try: @@ -232,11 +232,9 @@ def try_pandoc(self): try: pypandoc.download_pandoc(delete_installer=True) - except Exception as err: - self.print_error(f"Unable to install pandoc: {err}") - return - - self.pandoc_available = True + self.pandoc_available = True + except Exception: + self.pandoc_available = False def html_to_markdown(self, page_source): from bs4 import BeautifulSoup From c9dbb962819ccc8a1c77693997fab8efd4e17e28 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 09:07:14 -0700 Subject: [PATCH 27/41] cli-41: relabled available mcps to configured, to not confuse configured with loaded. --- cecli/commands/list_mcp.py | 18 ++++++++---------- cecli/tools/list_mcp.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/cecli/commands/list_mcp.py b/cecli/commands/list_mcp.py index 5342c6c8fb8..fbdc6a7e6ba 100644 --- a/cecli/commands/list_mcp.py +++ b/cecli/commands/list_mcp.py @@ -1,24 +1,22 @@ -from typing import List - from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result class ListMcpCommand(BaseCommand): NORM_NAME = "list-mcp" - DESCRIPTION = "List all loaded and available MCP servers." + DESCRIPTION = "List all loaded and configured MCP servers." @classmethod async def execute(cls, io, coder, args, **kwargs): """Execute the list-mcp command.""" if not coder.mcp_manager: - return format_command_result(io, cls.NORM_NAME, "MCP manager is not available.") + return format_command_result(io, cls.NORM_NAME, "MCP manager is not configured.") all_servers = coder.mcp_manager.servers connected_servers = coder.mcp_manager.connected_servers loaded_server_names = {server.name for server in connected_servers} - available_servers = [ + configured_servers = [ server for server in all_servers if server.name not in loaded_server_names ] @@ -32,12 +30,12 @@ async def execute(cls, io, coder, args, **kwargs): result.append("") - if available_servers: - result.append("Available MCP Servers:") - for server in sorted(available_servers, key=lambda s: s.name): + if configured_servers: + result.append("Configured MCP Servers:") + for server in sorted(configured_servers, key=lambda s: s.name): result.append(f"- {server.name}") else: - result.append("No other MCP servers are available to load.") + result.append("No other MCP servers are configured.") return format_command_result(io, cls.NORM_NAME, "", "\n".join(result)) @@ -46,5 +44,5 @@ def get_help(cls) -> str: """Get help text for the list-mcp command.""" help_text = super().get_help() help_text += "\nUsage:\n" - help_text += " /list-mcp # Lists all loaded and available MCP servers\n" + help_text += " /list-mcp # Lists all loaded and configured MCP servers\n" return help_text diff --git a/cecli/tools/list_mcp.py b/cecli/tools/list_mcp.py index 0facedb5a5d..6df6daca038 100644 --- a/cecli/tools/list_mcp.py +++ b/cecli/tools/list_mcp.py @@ -7,7 +7,7 @@ class Tool(BaseTool): "type": "function", "function": { "name": "ListMcp", - "description": "List all loaded and available MCP servers.", + "description": "List all loaded and configured MCP servers.", "parameters": { "type": "object", "properties": {}, @@ -18,15 +18,15 @@ class Tool(BaseTool): @classmethod def execute(cls, coder, **kwargs): - """List all loaded and available MCP servers.""" + """List all loaded and configured MCP servers.""" if not coder.mcp_manager: - return "MCP manager is not available." + return "MCP manager is not configured." all_servers = coder.mcp_manager.servers connected_servers = coder.mcp_manager.connected_servers loaded_server_names = {server.name for server in connected_servers} - available_servers = [ + configured_servers = [ server for server in all_servers if server.name not in loaded_server_names ] @@ -40,11 +40,11 @@ def execute(cls, coder, **kwargs): result.append("") - if available_servers: - result.append("Available MCP Servers:") - for server in sorted(available_servers, key=lambda s: s.name): + if configured_servers: + result.append("Configured MCP Servers:") + for server in sorted(configured_servers, key=lambda s: s.name): result.append(f"- {server.name}") else: - result.append("No other MCP servers are available to load.") + result.append("No other MCP servers are configured.") return "\n".join(result) From 1c1b63441f7f93de5ed96b00437b7f6d47929014 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 09:42:19 -0700 Subject: [PATCH 28/41] cli-41 --- cecli/commands/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 05e352a66ea..d22c2f4b36d 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -34,6 +34,7 @@ from .hooks import HooksCommand from .include_skill import IncludeSkillCommand from .lint import LintCommand +from .list_mcp import ListMcpCommand from .list_sessions import ListSessionsCommand from .list_skills import ListSkillsCommand from .load import LoadCommand @@ -121,6 +122,7 @@ CommandRegistry.register(SwitchAgentCommand) CommandRegistry.register(IncludeSkillCommand) CommandRegistry.register(LintCommand) +CommandRegistry.register(ListMcpCommand) CommandRegistry.register(ListSessionsCommand) CommandRegistry.register(ListSkillsCommand) CommandRegistry.register(LoadCommand) @@ -207,6 +209,7 @@ "LoadCommand", "LoadHookCommand", "LoadMcpCommand", + "ListMcpCommand", "LoadSessionCommand", "LoadSkillCommand", "LsCommand", From 587783336f54c4f55e856f997ac89e1304b4f4c3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 12:45:49 -0400 Subject: [PATCH 29/41] #537: Add lazy imports to main.py to help improve startup speed --- cecli/coders/base_coder.py | 21 ++++--- cecli/helpers/file_system/service.py | 11 ++-- cecli/helpers/responses.py | 16 +++++- cecli/llm.py | 1 + cecli/main.py | 82 +++++++++++++++++++--------- tests/basic/test_coder.py | 8 +-- tests/basic/test_main.py | 29 ++++++---- 7 files changed, 115 insertions(+), 53 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 672a0d1a66a..9c2a26d69d2 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -31,12 +31,6 @@ from urllib.parse import urlparse from uuid import uuid4 as generate_unique_id -import httpx -from litellm import experimental_mcp_client -from litellm.types.utils import ModelResponse -from prompt_toolkit.patch_stdout import patch_stdout -from rich.console import Console - import cecli.prompts.utils.system as prompts from cecli import __version__, models, urls, utils from cecli.commands import Commands, SwitchCoderSignal @@ -1489,6 +1483,8 @@ async def run(self, with_message=None, preproc=True): return await self._run_linear(with_message, preproc) if self.io.prompt_session: + from prompt_toolkit.patch_stdout import patch_stdout + with patch_stdout(raw=True): return await self._run_parallel(with_message, preproc) else: @@ -1973,7 +1969,10 @@ async def check_for_urls(self, inp: str) -> List[str]: def keyboard_interrupt(self): # Ensure cursor is visible on exit - Console().show_cursor(True) + if not self.tui: + from rich.console import Console + + Console().show_cursor(True) self.io.tool_warning("^C KeyboardInterrupt") self.interrupt_event.set() @@ -2900,6 +2899,8 @@ async def _execute_local_tools(self, tool_calls): async def _execute_mcp_tools(self, server, tool_calls): """Execute MCP tools via LiteLLM.""" + import httpx + tool_responses = [] try: # Connect to the server once @@ -2947,6 +2948,8 @@ async def _execute_mcp_tools(self, server, tool_calls): continue async def do_tool_call(): + from litellm import experimental_mcp_client + return await experimental_mcp_client.call_openai_tool( session=session, openai_tool=new_tool_call, @@ -3385,6 +3388,8 @@ async def check_for_file_mentions(self, content): return prompts.added_files.format(fnames=", ".join(added_fnames)) async def send(self, messages, model=None, functions=None, tools=None): + from litellm.types.utils import ModelResponse + self.interrupt_event.clear() self.got_reasoning_content = False self.ended_reasoning_content = False @@ -3471,6 +3476,8 @@ async def send(self, messages, model=None, functions=None, tools=None): self.io.ai_output(json.dumps(args, indent=4)) async def show_send_output(self, completion): + from litellm.types.utils import ModelResponse + if self.verbose: print(completion) diff --git a/cecli/helpers/file_system/service.py b/cecli/helpers/file_system/service.py index 42ed8ff83ca..478f683ad47 100644 --- a/cecli/helpers/file_system/service.py +++ b/cecli/helpers/file_system/service.py @@ -71,16 +71,19 @@ def reset_instance(cls) -> None: cls._instance = None @classmethod - def get_instance(cls, root: str = ".", repo=None) -> "FileSystemService": + def get_instance(cls, root: str | None = None, repo=None) -> "FileSystemService": """ Return the global singleton. On first call, creates and builds the instance using root/repo. - Subsequent calls return the existing instance (root/repo - parameters are ignored). This ensures all agents share one - file index regardless of when they're spawned. + Subsequent calls return the existing instance. If a non-None root + is explicitly provided and differs from the current root, the + singleton is rebuilt (e.g. when a new coder is created in a + different working directory). """ if cls._instance is None: + cls._instance = cls._create(root=root or ".", repo=repo) + elif root is not None and root != cls._instance.root: cls._instance = cls._create(root=root, repo=repo) return cls._instance diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py index 0bc9cecf909..c82200419b3 100644 --- a/cecli/helpers/responses.py +++ b/cecli/helpers/responses.py @@ -1,14 +1,16 @@ import json import re import time -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional import json_repair -from litellm.types.utils import ChatCompletionMessageToolCall, Function from cecli import utils from cecli.helpers import nested +if TYPE_CHECKING: + from litellm.types.utils import ChatCompletionMessageToolCall, Function # noqa + def preprocess_json(response: str) -> str: # This pattern matches any sequence of backslashes followed by @@ -35,6 +37,8 @@ def extract_tools_from_content_json(content: str) -> Optional[List[ChatCompletio Simple extraction of JSON-like structures that look like tool calls. This handles models that write JSON in text instead of using native calling. """ + from litellm.types.utils import ChatCompletionMessageToolCall, Function # noqa + if not content or ("{" not in content and "[" not in content): return None @@ -111,6 +115,8 @@ def extract_tools_from_content_xml(content: str) -> Optional[List[ChatCompletion """ + from litellm.types.utils import ChatCompletionMessageToolCall, Function # noqa + if not content or (" Date: Sun, 21 Jun 2026 10:05:50 -0700 Subject: [PATCH 30/41] cli-41: list --- cecli/commands/list_mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/commands/list_mcp.py b/cecli/commands/list_mcp.py index fbdc6a7e6ba..20457590e90 100644 --- a/cecli/commands/list_mcp.py +++ b/cecli/commands/list_mcp.py @@ -37,7 +37,7 @@ async def execute(cls, io, coder, args, **kwargs): else: result.append("No other MCP servers are configured.") - return format_command_result(io, cls.NORM_NAME, "", "\n".join(result)) + return format_command_result(io, cls.NORM_NAME, "\n".join(result)) @classmethod def get_help(cls) -> str: From 9f7a44197c9b5b569ad81e0c8066110c7ed106a6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 13:25:13 -0400 Subject: [PATCH 31/41] Add annotation helper for ci/cd --- cecli/helpers/responses.py | 2 ++ cecli/tools/utils/__init__.py | 0 2 files changed, 2 insertions(+) create mode 100644 cecli/tools/utils/__init__.py diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py index c82200419b3..8a77db27bb4 100644 --- a/cecli/helpers/responses.py +++ b/cecli/helpers/responses.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import re import time diff --git a/cecli/tools/utils/__init__.py b/cecli/tools/utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d From 159fa6b930922a71893a648a92cef0c8e3daa48c Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 13:39:44 -0400 Subject: [PATCH 32/41] Update metadata --- cecli/resources/model-metadata.json | 2192 +++++++++++++++++++++++++-- 1 file changed, 2088 insertions(+), 104 deletions(-) diff --git a/cecli/resources/model-metadata.json b/cecli/resources/model-metadata.json index 8aee168a3e5..4af6e04ffc7 100644 --- a/cecli/resources/model-metadata.json +++ b/cecli/resources/model-metadata.json @@ -353,6 +353,39 @@ "cache_read_input_token_cost": 3e-7, "cache_creation_input_token_cost": 0.00000375 }, + "anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 0.000001, + "input_cost_per_token": 0.00001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, "anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, @@ -557,6 +590,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -581,6 +615,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -588,6 +623,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1019,6 +1055,7 @@ }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, + "cache_creation_input_token_cost_above_1hr": 0.0000022, "cache_read_input_token_cost": 1.1e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "bedrock_converse", @@ -1040,6 +1077,7 @@ }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -1069,6 +1107,7 @@ }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -1089,6 +1128,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1113,6 +1153,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1120,6 +1161,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1130,11 +1172,13 @@ }, "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "input_cost_per_token_above_200k_tokens": 0.0000066, "output_cost_per_token_above_200k_tokens": 0.00002475, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132, "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -1160,6 +1204,7 @@ }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "litellm_provider": "bedrock_converse", @@ -4531,6 +4576,36 @@ "supports_tool_choice": true, "supports_reasoning": true }, + "azure_ai/claude-fable-5": { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 0.000001, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, "azure_ai/claude-haiku-4-5": { "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, @@ -4646,6 +4721,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -4667,6 +4743,7 @@ "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -4674,6 +4751,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -4758,6 +4836,19 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "azure_ai/deepseek-v3.1": { + "input_cost_per_token": 0.00000123, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.00000494, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "azure_ai/deepseek-v3.2": { "input_cost_per_token": 5.8e-7, "litellm_provider": "azure_ai", @@ -4788,6 +4879,32 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "azure_ai/deepseek-v4-flash": { + "input_cost_per_token": 1.9e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 5.1e-7, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "azure_ai/deepseek-v4-pro": { + "input_cost_per_token": 0.00000174, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 0.00000348, + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "azure_ai/global/grok-3": { "input_cost_per_token": 0.000003, "litellm_provider": "azure_ai", @@ -5099,6 +5216,100 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure_ai/gpt-5.5": { + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_272k_tokens": 0.000001, + "cache_read_input_token_cost_priority": 0.000001, + "cache_read_input_token_cost_above_272k_tokens_priority": 0.000002, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_272k_tokens": 0.00001, + "input_cost_per_token_priority": 0.00001, + "input_cost_per_token_above_272k_tokens_priority": 0.00002, + "litellm_provider": "azure_ai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "output_cost_per_token_above_272k_tokens": 0.000045, + "output_cost_per_token_priority": 0.00006, + "output_cost_per_token_above_272k_tokens_priority": 0.00009, + "source": "https://ai.azure.com/catalog/models/gpt-5.5", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure_ai/gpt-5.5-2026-04-23": { + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_272k_tokens": 0.000001, + "cache_read_input_token_cost_priority": 0.000001, + "cache_read_input_token_cost_above_272k_tokens_priority": 0.000002, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_272k_tokens": 0.00001, + "input_cost_per_token_priority": 0.00001, + "input_cost_per_token_above_272k_tokens_priority": 0.00002, + "litellm_provider": "azure_ai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00003, + "output_cost_per_token_above_272k_tokens": 0.000045, + "output_cost_per_token_priority": 0.00006, + "output_cost_per_token_above_272k_tokens_priority": 0.00009, + "source": "https://ai.azure.com/catalog/models/gpt-5.5", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, "azure_ai/gpt-oss-120b": { "input_cost_per_token": 1.5e-7, "output_cost_per_token": 6e-7, @@ -5260,6 +5471,27 @@ "supports_video_input": true, "supports_vision": true }, + "azure_ai/kimi-k2.6": { + "input_cost_per_token": 9.5e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000004, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-kimi-k2-6-in-microsoft-foundry/4513125", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "azure_ai/ministral-3b": { "input_cost_per_token": 4e-8, "litellm_provider": "azure_ai", @@ -6729,6 +6961,7 @@ }, "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.0000015, + "cache_creation_input_token_cost_above_1hr": 0.0000024, "cache_read_input_token_cost": 1.2e-7, "input_cost_per_token": 0.0000012, "litellm_provider": "bedrock", @@ -6750,15 +6983,16 @@ "supports_pdf_input": true }, "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { - "cache_creation_input_token_cost": 0.000004125, - "cache_read_input_token_cost": 3.3e-7, - "input_cost_per_token": 0.0000033, + "cache_creation_input_token_cost": 0.0000045, + "cache_creation_input_token_cost_above_1hr": 0.0000072, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000018, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -6771,15 +7005,16 @@ "supports_native_structured_output": true }, "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { - "cache_creation_input_token_cost": 0.000004125, - "cache_read_input_token_cost": 3.3e-7, - "input_cost_per_token": 0.0000033, + "cache_creation_input_token_cost": 0.0000045, + "cache_creation_input_token_cost_above_1hr": 0.0000072, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000018, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -6906,6 +7141,7 @@ }, "bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.0000015, + "cache_creation_input_token_cost_above_1hr": 0.0000024, "cache_read_input_token_cost": 1.2e-7, "input_cost_per_token": 0.0000012, "litellm_provider": "bedrock", @@ -6927,15 +7163,16 @@ "supports_pdf_input": true }, "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { - "cache_creation_input_token_cost": 0.000004125, - "cache_read_input_token_cost": 3.3e-7, - "input_cost_per_token": 0.0000033, + "cache_creation_input_token_cost": 0.0000045, + "cache_creation_input_token_cost_above_1hr": 0.0000072, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000018, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -6948,15 +7185,16 @@ "supports_native_structured_output": true }, "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { - "cache_creation_input_token_cost": 0.000004125, - "cache_read_input_token_cost": 3.3e-7, - "input_cost_per_token": 0.0000033, + "cache_creation_input_token_cost": 0.0000045, + "cache_creation_input_token_cost_above_1hr": 0.0000072, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000018, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -7232,6 +7470,63 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "bedrock_mantle/google.gemma-4-26b-a4b": { + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/google.gemma-4-31b": { + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/google.gemma-4-e2b": { + "input_cost_per_token": 4e-8, + "output_cost_per_token": 8e-8, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock_mantle/openai.gpt-oss-120b": { "input_cost_per_token": 1.5e-7, "output_cost_per_token": 6e-7, @@ -7240,6 +7535,10 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -7254,6 +7553,10 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -7268,6 +7571,9 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions" + ], "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -7281,6 +7587,9 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions" + ], "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -7579,6 +7888,40 @@ "supports_vision": true, "supports_web_search": true }, + "claude-fable-5": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 0.000001, + "input_cost_per_token": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1 + }, + "supports_output_config": true + }, "claude-haiku-4-5": { "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, @@ -7845,6 +8188,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -7879,6 +8223,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -7913,6 +8258,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -7956,6 +8302,8 @@ }, "claude-sonnet-4-5": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -7985,6 +8333,8 @@ }, "claude-sonnet-4-5-20250929": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -8015,6 +8365,8 @@ }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -8039,6 +8391,7 @@ }, "claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "anthropic", @@ -8231,13 +8584,13 @@ "supports_tool_choice": true }, "command-r7b-12-2024": { - "input_cost_per_token": 1.5e-7, + "input_cost_per_token": 3.75e-8, "litellm_provider": "cohere_chat", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", - "output_cost_per_token": 3.75e-8, + "output_cost_per_token": 1.5e-7, "source": "https://docs.cohere.com/v2/docs/command-r7b", "supports_function_calling": true, "supports_tool_choice": true @@ -9980,7 +10333,8 @@ "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, - "supports_function_calling": true + "supports_function_calling": true, + "supports_image_size": false }, "deepinfra/google/gemini-2.5-pro": { "max_tokens": 1000000, @@ -10377,6 +10731,56 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "deepseek-v4-flash": { + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 2.8e-9, + "input_cost_per_token": 1.4e-7, + "input_cost_per_token_cache_hit": 2.8e-9, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.8e-7, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "deepseek-v4-pro": { + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 3.625e-9, + "input_cost_per_token": 4.35e-7, + "input_cost_per_token_cache_hit": 3.625e-9, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8.7e-7, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, "deepseek.v3-v1:0": { "input_cost_per_token": 5.8e-7, "litellm_provider": "bedrock_converse", @@ -10511,6 +10915,56 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "deepseek/deepseek-v4-flash": { + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 2.8e-9, + "input_cost_per_token": 1.4e-7, + "input_cost_per_token_cache_hit": 2.8e-9, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.8e-7, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "deepseek/deepseek-v4-pro": { + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 3.625e-9, + "input_cost_per_token": 4.35e-7, + "input_cost_per_token_cache_hit": 3.625e-9, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8.7e-7, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, "eu.amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 8.25e-8, "input_cost_per_token": 3.3e-7, @@ -10707,8 +11161,42 @@ "cache_read_input_token_cost": 3e-7, "cache_creation_input_token_cost": 0.00000375 }, + "eu.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000022, + "cache_read_input_token_cost": 0.0000011, + "input_cost_per_token": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000055, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, + "cache_creation_input_token_cost_above_1hr": 0.0000022, "cache_read_input_token_cost": 1.1e-7, "input_cost_per_token": 0.0000011, "deprecation_date": "2026-10-15", @@ -10810,6 +11298,7 @@ }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -10839,6 +11328,7 @@ }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -10859,6 +11349,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10883,6 +11374,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -10890,6 +11382,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10929,11 +11422,13 @@ }, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "input_cost_per_token_above_200k_tokens": 0.0000066, "output_cost_per_token_above_200k_tokens": 0.00002475, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132, "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -10959,6 +11454,7 @@ }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "litellm_provider": "bedrock_converse", @@ -11524,6 +12020,38 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash": { + "cache_read_input_token_cost": 2.8e-8, + "input_cost_per_token": 1.4e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 2.8e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { + "cache_read_input_token_cost": 1.45e-7, + "input_cost_per_token": 0.00000174, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 0.00000348, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "fireworks_ai/accounts/fireworks/models/devstral-small-2505": { "max_tokens": 131072, "max_input_tokens": 131072, @@ -11780,43 +12308,64 @@ "input_cost_per_token": 0.0000014, "litellm_provider": "fireworks_ai", "max_input_tokens": 202800, - "max_output_tokens": 202800, - "max_tokens": 202800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 0.0000044, - "source": "https://fireworks.ai/models/fireworks/glm-5p1", - "supports_function_calling": false, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": false + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/glm-5p2": { + "cache_read_input_token_cost": 2.6e-7, + "input_cost_per_token": 0.0000014, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { + "cache_read_input_token_cost": 1.5e-8, "input_cost_per_token": 1.5e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-7, - "source": "https://fireworks.ai/pricing", + "source": "https://docs.fireworks.ai/serverless/pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": false }, "fireworks_ai/accounts/fireworks/models/gpt-oss-20b": { - "input_cost_per_token": 5e-8, + "cache_read_input_token_cost": 3.5e-8, + "input_cost_per_token": 7e-8, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 2e-7, - "source": "https://fireworks.ai/pricing", + "output_cost_per_token": 3e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": false }, "fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-120b": { "max_tokens": 131072, @@ -11953,6 +12502,38 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/kimi-k2p6": { + "cache_read_input_token_cost": 1.6e-7, + "input_cost_per_token": 9.5e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000004, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/models/kimi-k2p7-code": { + "cache_read_input_token_cost": 1.9e-7, + "input_cost_per_token": 9.5e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000004, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/models/llama-guard-2-8b": { "max_tokens": 8192, "max_input_tokens": 8192, @@ -12286,6 +12867,38 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { + "cache_read_input_token_cost": 6e-8, + "input_cost_per_token": 3e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 196608, + "max_output_tokens": 196608, + "max_tokens": 196608, + "mode": "chat", + "output_cost_per_token": 0.0000012, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/minimax-m3": { + "cache_read_input_token_cost": 6e-8, + "input_cost_per_token": 3e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 512000, + "max_output_tokens": 512000, + "max_tokens": 512000, + "mode": "chat", + "output_cost_per_token": 0.0000012, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/models/ministral-3-14b-instruct-2512": { "max_tokens": 256000, "max_input_tokens": 256000, @@ -13228,6 +13841,22 @@ "litellm_provider": "fireworks_ai", "mode": "chat" }, + "fireworks_ai/accounts/fireworks/models/qwen3p7-plus": { + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 4e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.0000016, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/models/qwq-32b": { "max_tokens": 131072, "max_input_tokens": 131072, @@ -13376,6 +14005,86 @@ "litellm_provider": "fireworks_ai", "mode": "chat" }, + "fireworks_ai/accounts/fireworks/routers/glm-5p1-fast": { + "cache_read_input_token_cost": 5.2e-7, + "input_cost_per_token": 0.0000028, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 202800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0000088, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/kimi-k2p6-fast": { + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000002, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000008, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/routers/kimi-k2p7-code-fast": { + "cache_read_input_token_cost": 3.8e-7, + "input_cost_per_token": 0.0000019, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000008, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/deepseek-v4-flash": { + "cache_read_input_token_cost": 2.8e-8, + "input_cost_per_token": 1.4e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 2.8e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/deepseek-v4-pro": { + "cache_read_input_token_cost": 1.45e-7, + "input_cost_per_token": 0.00000174, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 0.00000348, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "fireworks_ai/glm-4p7": { "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 6e-7, @@ -13396,15 +14105,80 @@ "input_cost_per_token": 0.0000014, "litellm_provider": "fireworks_ai", "max_input_tokens": 202800, - "max_output_tokens": 202800, - "max_tokens": 202800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 0.0000044, - "source": "https://fireworks.ai/models/fireworks/glm-5p1", - "supports_function_calling": false, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": false + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/glm-5p1-fast": { + "cache_read_input_token_cost": 5.2e-7, + "input_cost_per_token": 0.0000028, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 202800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0000088, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/glm-5p2": { + "cache_read_input_token_cost": 2.6e-7, + "input_cost_per_token": 0.0000014, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0000044, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/gpt-oss-120b": { + "cache_read_input_token_cost": 1.5e-8, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/gpt-oss-20b": { + "cache_read_input_token_cost": 3.5e-8, + "input_cost_per_token": 7e-8, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-7, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false }, "fireworks_ai/kimi-k2p5": { "cache_read_input_token_cost": 1e-7, @@ -13420,6 +14194,70 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/kimi-k2p6": { + "cache_read_input_token_cost": 1.6e-7, + "input_cost_per_token": 9.5e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000004, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k2p6-fast": { + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000002, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000008, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k2p7-code": { + "cache_read_input_token_cost": 1.9e-7, + "input_cost_per_token": 9.5e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000004, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k2p7-code-fast": { + "cache_read_input_token_cost": 3.8e-7, + "input_cost_per_token": 0.0000019, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.000008, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/minimax-m2p1": { "cache_read_input_token_cost": 3e-8, "input_cost_per_token": 3e-7, @@ -13434,6 +14272,54 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/minimax-m2p7": { + "cache_read_input_token_cost": 6e-8, + "input_cost_per_token": 3e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 196608, + "max_output_tokens": 196608, + "max_tokens": 196608, + "mode": "chat", + "output_cost_per_token": 0.0000012, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/minimax-m3": { + "cache_read_input_token_cost": 6e-8, + "input_cost_per_token": 3e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 512000, + "max_output_tokens": 512000, + "max_tokens": 512000, + "mode": "chat", + "output_cost_per_token": 0.0000012, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/qwen3p7-plus": { + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 4e-7, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.0000016, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "friendliai/meta-llama-3.1-70b-instruct": { "input_cost_per_token": 6e-7, "litellm_provider": "friendliai", @@ -13893,7 +14779,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_service_tier": true + "supports_service_tier": true, + "supports_image_size": false }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-8, @@ -13943,7 +14830,8 @@ "supports_vision": true, "supports_web_search": false, "tpm": 8000000, - "supports_service_tier": true + "supports_service_tier": true, + "supports_image_size": false }, "gemini-2.5-flash-lite": { "cache_read_input_token_cost": 1e-8, @@ -13994,7 +14882,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_service_tier": true + "supports_service_tier": true, + "supports_image_size": false }, "gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -14045,7 +14934,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "supports_image_size": false }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-8, @@ -14095,7 +14985,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "supports_image_size": false }, "gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 0.000001, @@ -14217,7 +15108,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "supports_image_size": false }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-7, @@ -15387,7 +16279,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_service_tier": true + "supports_service_tier": true, + "supports_image_size": false }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-8, @@ -15443,7 +16336,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_service_tier": true + "supports_service_tier": true, + "supports_image_size": false }, "gemini/gemini-2.5-flash-lite": { "cache_read_input_token_cost": 1e-8, @@ -15496,7 +16390,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_service_tier": true + "supports_service_tier": true, + "supports_image_size": false }, "gemini/gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -15549,7 +16444,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "supports_image_size": false }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-8, @@ -15601,7 +16497,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "supports_image_size": false }, "gemini/gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 0.000001, @@ -15731,7 +16628,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "supports_image_size": false }, "gemini/gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-7, @@ -17088,6 +17986,38 @@ "supports_response_schema": true, "supports_vision": true }, + "github_copilot/mai-code-1-flash": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_token": 7.5e-7, + "litellm_provider": "github_copilot", + "max_input_tokens": 128000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000045, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "github_copilot/mai-code-1-flash-internal": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_token": 7.5e-7, + "litellm_provider": "github_copilot", + "max_input_tokens": 128000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000045, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, "glm-4-7-251222": { "input_cost_per_token": 0, "litellm_provider": "volcengine", @@ -17119,6 +18049,39 @@ "supports_video_input": true, "supports_vision": true }, + "global.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 0.000001, + "input_cost_per_token": 0.00001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, @@ -17224,6 +18187,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -17248,6 +18212,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -17255,6 +18220,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -20412,6 +21378,21 @@ "supports_system_messages": true, "supports_tool_choice": true }, + "inception/mercury-2": { + "cache_read_input_token_cost": 2.5e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "inception", + "max_input_tokens": 128000, + "max_output_tokens": 50000, + "max_tokens": 50000, + "mode": "chat", + "output_cost_per_token": 7.5e-7, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, "jamba-1.5": { "input_cost_per_token": 2e-7, "litellm_provider": "ai21", @@ -20504,6 +21485,7 @@ }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, + "cache_creation_input_token_cost_above_1hr": 0.0000022, "cache_read_input_token_cost": 1.1e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "bedrock_converse", @@ -20524,13 +21506,46 @@ "supports_vision": true, "supports_native_structured_output": true }, + "jp.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "input_cost_per_token_above_200k_tokens": 0.0000066, "output_cost_per_token_above_200k_tokens": 0.00002475, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132, "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -20556,6 +21571,7 @@ }, "jp.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "litellm_provider": "bedrock_converse", @@ -20921,6 +21937,165 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "libertai/deepseek-v4-flash": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000175, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": false, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/deepseek-v4-flash-thinking": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000175, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": false, + "supports_reasoning": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/gemma-4-31b-it": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/gemma-4-31b-it-thinking": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 4e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_reasoning": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/hermes-3-8b-tee": { + "max_tokens": 16000, + "max_input_tokens": 16000, + "max_output_tokens": 16000, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": false, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/qwen3.5-122b-a10b": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000175, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/qwen3.5-122b-a10b-thinking": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000175, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_reasoning": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/qwen3.6-27b": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 5e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/qwen3.6-27b-thinking": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 5e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_reasoning": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/qwen3.6-35b-a3b": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 5e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "source": "https://docs.libertai.io/apis/text/" + }, + "libertai/qwen3.6-35b-a3b-thinking": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 5e-7, + "litellm_provider": "libertai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_reasoning": true, + "source": "https://docs.libertai.io/apis/text/" + }, "llamagate/codellama-7b": { "max_tokens": 4096, "max_input_tokens": 16384, @@ -21448,6 +22623,24 @@ "max_input_tokens": 1000000, "max_output_tokens": 8192 }, + "minimax/MiniMax-M3": { + "input_cost_per_token": 3e-7, + "input_cost_per_token_above_512k_tokens": 6e-7, + "output_cost_per_token": 0.0000012, + "output_cost_per_token_above_512k_tokens": 0.0000024, + "cache_read_input_token_cost": 6e-8, + "cache_read_input_token_cost_above_512k_tokens": 1.2e-7, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true, + "max_input_tokens": 1000000, + "max_output_tokens": 128000 + }, "mistral.devstral-2-123b": { "input_cost_per_token": 4e-7, "litellm_provider": "bedrock_converse", @@ -21923,6 +23116,21 @@ "supports_tool_choice": true, "supports_vision": true }, + "mistral/ministral-8b-latest": { + "input_cost_per_token": 1.5e-7, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-7, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "mistral/mistral-large-2402": { "input_cost_per_token": 0.000004, "litellm_provider": "mistral", @@ -22059,6 +23267,21 @@ "supports_tool_choice": true, "supports_vision": true }, + "mistral/mistral-medium-3-5": { + "input_cost_per_token": 0.0000015, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0000075, + "source": "https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "mistral/mistral-medium-latest": { "input_cost_per_token": 4e-7, "litellm_provider": "mistral", @@ -22260,6 +23483,7 @@ }, "moonshot/kimi-k2-0711-preview": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-05-25", "input_cost_per_token": 6e-7, "litellm_provider": "moonshot", "max_input_tokens": 131072, @@ -22274,6 +23498,7 @@ }, "moonshot/kimi-k2-0905-preview": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-05-25", "input_cost_per_token": 6e-7, "litellm_provider": "moonshot", "max_input_tokens": 262144, @@ -22288,6 +23513,7 @@ }, "moonshot/kimi-k2-thinking": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-05-25", "input_cost_per_token": 6e-7, "litellm_provider": "moonshot", "max_input_tokens": 262144, @@ -22303,6 +23529,7 @@ }, "moonshot/kimi-k2-thinking-turbo": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-05-25", "input_cost_per_token": 0.00000115, "litellm_provider": "moonshot", "max_input_tokens": 262144, @@ -22318,6 +23545,7 @@ }, "moonshot/kimi-k2-turbo-preview": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-05-25", "input_cost_per_token": 0.00000115, "litellm_provider": "moonshot", "max_input_tokens": 262144, @@ -22342,6 +23570,7 @@ "source": "https://platform.moonshot.ai/docs/guide/kimi-k2-5-quickstart", "supports_function_calling": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true @@ -22358,12 +23587,14 @@ "source": "https://platform.kimi.ai/docs/pricing/chat-k26", "supports_function_calling": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true }, "moonshot/kimi-latest": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-01-28", "input_cost_per_token": 0.000002, "litellm_provider": "moonshot", "max_input_tokens": 131072, @@ -22378,6 +23609,7 @@ }, "moonshot/kimi-latest-128k": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-01-28", "input_cost_per_token": 0.000002, "litellm_provider": "moonshot", "max_input_tokens": 131072, @@ -22392,6 +23624,7 @@ }, "moonshot/kimi-latest-32k": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-01-28", "input_cost_per_token": 0.000001, "litellm_provider": "moonshot", "max_input_tokens": 32768, @@ -22406,6 +23639,7 @@ }, "moonshot/kimi-latest-8k": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2026-01-28", "input_cost_per_token": 2e-7, "litellm_provider": "moonshot", "max_input_tokens": 8192, @@ -22420,6 +23654,7 @@ }, "moonshot/kimi-thinking-preview": { "cache_read_input_token_cost": 1.5e-7, + "deprecation_date": "2025-11-11", "input_cost_per_token": 6e-7, "litellm_provider": "moonshot", "max_input_tokens": 131072, @@ -22440,9 +23675,11 @@ "output_cost_per_token": 0.000005, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-128k-0430": { + "deprecation_date": "2024-04-30", "input_cost_per_token": 0.000002, "litellm_provider": "moonshot", "max_input_tokens": 131072, @@ -22464,6 +23701,7 @@ "output_cost_per_token": 0.000005, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, @@ -22477,9 +23715,11 @@ "output_cost_per_token": 0.000003, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-32k-0430": { + "deprecation_date": "2024-04-30", "input_cost_per_token": 0.000001, "litellm_provider": "moonshot", "max_input_tokens": 32768, @@ -22501,6 +23741,7 @@ "output_cost_per_token": 0.000003, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, @@ -22514,9 +23755,11 @@ "output_cost_per_token": 0.000002, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-8k-0430": { + "deprecation_date": "2024-04-30", "input_cost_per_token": 2e-7, "litellm_provider": "moonshot", "max_input_tokens": 8192, @@ -22538,6 +23781,7 @@ "output_cost_per_token": 0.000002, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, @@ -22551,6 +23795,7 @@ "output_cost_per_token": 0.000005, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "moonshotai.kimi-k2.5": { @@ -24619,7 +25864,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_vision": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "supports_image_size": false }, "oci/google.gemini-2.5-flash-lite": { "input_cost_per_token": 7.5e-8, @@ -24633,7 +25879,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_vision": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "supports_image_size": false }, "oci/google.gemini-2.5-pro": { "input_cost_per_token": 0.00000125, @@ -25572,7 +26819,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_image_size": false }, "openrouter/google/gemini-2.5-pro": { "input_cost_per_audio_token": 7e-7, @@ -27207,6 +28455,84 @@ "mode": "chat", "output_cost_per_token": 2.8e-7 }, + "pinstripes/ps/deepseek-v4-flash": { + "max_tokens": 163840, + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 2e-7, + "litellm_provider": "pinstripes", + "mode": "chat", + "supports_function_calling": true, + "supports_assistant_prefill": true, + "supports_reasoning": true, + "source": "https://pinstripes.io/pricing" + }, + "pinstripes/ps/glm-4.5-air": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.25e-7, + "output_cost_per_token": 4.5e-7, + "litellm_provider": "pinstripes", + "mode": "chat", + "supports_function_calling": true, + "supports_assistant_prefill": true, + "supports_reasoning": true, + "source": "https://pinstripes.io/pricing" + }, + "pinstripes/ps/minimax-m2.7": { + "max_tokens": 1000192, + "max_input_tokens": 1000192, + "max_output_tokens": 1000192, + "input_cost_per_token": 2.55e-7, + "output_cost_per_token": 5.5e-7, + "litellm_provider": "pinstripes", + "mode": "chat", + "supports_function_calling": true, + "supports_assistant_prefill": true, + "supports_reasoning": false, + "source": "https://pinstripes.io/pricing" + }, + "pinstripes/ps/qwen3-30b-a3b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 2e-7, + "litellm_provider": "pinstripes", + "mode": "chat", + "supports_function_calling": true, + "supports_assistant_prefill": true, + "supports_reasoning": true, + "source": "https://pinstripes.io/pricing" + }, + "pinstripes/ps/qwen3-coder-30b-a3b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "pinstripes", + "mode": "chat", + "supports_function_calling": true, + "supports_assistant_prefill": true, + "supports_reasoning": false, + "source": "https://pinstripes.io/pricing" + }, + "pinstripes/ps/qwen3.6-35b-a3b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 4.5e-7, + "litellm_provider": "pinstripes", + "mode": "chat", + "supports_function_calling": true, + "supports_assistant_prefill": true, + "supports_reasoning": true, + "source": "https://pinstripes.io/pricing" + }, "publicai/BSC-LT/ALIA-40b-instruct_Q8_0": { "input_cost_per_token": 0, "litellm_provider": "publicai", @@ -27530,7 +28856,8 @@ "supports_vision": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_response_schema": true + "supports_response_schema": true, + "supports_image_size": false }, "replicate/google/gemini-3-pro": { "input_cost_per_token": 0.000002, @@ -28079,21 +29406,278 @@ "output_cost_per_token": 0, "supports_reasoning": true }, - "snowflake/claude-3-5-sonnet": { - "litellm_provider": "snowflake", - "max_input_tokens": 18000, + "scaleway/google/gemma-3-27b-it": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 40000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "supports_computer_use": true + "output_cost_per_token": 5e-7, + "supports_function_calling": true, + "supports_vision": true + }, + "scaleway/google/gemma-4-26b-a4b-it": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 5e-7, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true + }, + "scaleway/hcompany/holo2-30b-a3b": { + "input_cost_per_token": 3e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 22000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 7e-7, + "supports_reasoning": true, + "supports_vision": true + }, + "scaleway/meta/llama-3.3-70b-instruct": { + "input_cost_per_token": 9e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 9e-7, + "supports_function_calling": true + }, + "scaleway/mistralai/devstral-2-123b-instruct-2512": { + "input_cost_per_token": 4e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.000002, + "supports_function_calling": true + }, + "scaleway/mistralai/mistral-medium-3.5-128b": { + "input_cost_per_token": 0.0000015, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.0000075, + "supports_reasoning": true, + "supports_function_calling": true, + "supports_vision": true, + "supports_tool_choice": true + }, + "scaleway/mistralai/mistral-small-3.2-24b-instruct-2506": { + "input_cost_per_token": 1.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3.5e-7, + "supports_function_calling": true, + "supports_vision": true + }, + "scaleway/mistralai/pixtral-12b-2409": { + "input_cost_per_token": 2e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-7, + "supports_vision": true, + "supports_function_calling": true + }, + "scaleway/mistralai/voxtral-small-24b-2507": { + "input_cost_per_audio_token": 1.5e-7, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 32000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 3.5e-7, + "supports_audio_input": true + }, + "scaleway/openai/gpt-oss-120b": { + "input_cost_per_token": 1.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-7, + "supports_function_calling": true + }, + "scaleway/qwen/qwen3-235b-a22b-instruct-2507": { + "input_cost_per_token": 7.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00000225, + "supports_function_calling": true + }, + "scaleway/qwen/qwen3-coder-30b-a3b-instruct": { + "input_cost_per_token": 2e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-7, + "supports_function_calling": true + }, + "scaleway/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.0000036, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_vision": true + }, + "scaleway/qwen/qwen3.6-35b-a3b": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "scaleway", + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_vision": true, + "supports_reasoning": true + }, + "snowflake/claude-3-5-sonnet": { + "litellm_provider": "snowflake", + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-3-7-sonnet": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "supports_response_schema": true + }, + "snowflake/claude-4-opus": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "supports_response_schema": true + }, + "snowflake/claude-4-sonnet": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-haiku-4-5": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-sonnet-4-5": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-sonnet-4-6": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true }, "snowflake/deepseek-r1": { "litellm_provider": "snowflake", - "max_input_tokens": 32768, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "supports_reasoning": true + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, + "supports_reasoning": true, + "supports_system_messages": true }, "snowflake/gemma-7b": { "litellm_provider": "snowflake", @@ -28147,23 +29731,34 @@ "snowflake/llama3.1-405b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat" + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "input_cost_per_token": 0.0000012, + "output_cost_per_token": 0.0000012, + "supports_function_calling": true, + "supports_system_messages": true }, "snowflake/llama3.1-70b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat" + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 7.2e-7, + "supports_function_calling": true, + "supports_system_messages": true }, "snowflake/llama3.1-8b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat" + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "input_cost_per_token": 2.4e-7, + "output_cost_per_token": 2.4e-7, + "supports_system_messages": true }, "snowflake/llama3.2-1b": { "litellm_provider": "snowflake", @@ -28180,11 +29775,26 @@ "mode": "chat" }, "snowflake/llama3.3-70b": { + "max_tokens": 16384, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 7.2e-7, "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true + }, + "snowflake/llama4-maverick": { + "max_tokens": 16384, "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat" + "max_output_tokens": 16384, + "input_cost_per_token": 2.4e-7, + "output_cost_per_token": 9.7e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true }, "snowflake/mistral-7b": { "litellm_provider": "snowflake", @@ -28203,9 +29813,14 @@ "snowflake/mistral-large2": { "litellm_provider": "snowflake", "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat" + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "supports_function_calling": true, + "supports_system_messages": true, + "supports_response_schema": true }, "snowflake/mixtral-8x7b": { "litellm_provider": "snowflake", @@ -28214,6 +29829,61 @@ "max_tokens": 8192, "mode": "chat" }, + "snowflake/openai-gpt-4.1": { + "max_tokens": 16384, + "max_input_tokens": 300000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-5": { + "max_tokens": 16384, + "max_input_tokens": 300000, + "max_output_tokens": 16384, + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-5-mini": { + "max_tokens": 16384, + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-5-nano": { + "max_tokens": 16384, + "max_input_tokens": 5000000, + "max_output_tokens": 16384, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_response_schema": true + }, "snowflake/reka-core": { "litellm_provider": "snowflake", "max_input_tokens": 32000, @@ -28243,11 +29913,174 @@ "mode": "chat" }, "snowflake/snowflake-llama-3.3-70b": { + "max_tokens": 16384, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 7.2e-7, "litellm_provider": "snowflake", - "max_input_tokens": 8000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat" + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true + }, + "tensormesh/MiniMaxAI/MiniMax-M2.5": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": 0, + "max_input_tokens": 196608, + "max_output_tokens": 196608, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 4.5e-7, + "output_cost_per_token": 0.0000018, + "cache_read_input_token_cost": 0, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/Qwen/Qwen3.5-397B-A17B-FP8": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000036, + "cache_read_input_token_cost": 0, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/Qwen/Qwen3.6-27B-FP8": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 3.2e-7, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": 0, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/deepseek-ai/DeepSeek-V4-Flash": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 2.8e-7, + "cache_read_input_token_cost": 0, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/google/gemma-4-31B-it": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 5.6e-7, + "cache_read_input_token_cost": 0, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/lukealonso/GLM-5.1-NVFP4-MTP": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 0.0000014, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 0, + "max_input_tokens": 202752, + "max_output_tokens": 202752, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/moonshotai/Kimi-K2.6": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 9.6e-7, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 0, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/openai/gpt-oss-120b": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 0, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/openai/gpt-oss-20b": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.8e-7, + "cache_read_input_token_cost": 0, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" }, "together-ai-21.1b-41b": { "input_cost_per_token": 8e-7, @@ -28658,19 +30491,21 @@ "supports_video_input": true }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { - "cache_creation_input_token_cost": 0.000004125, - "cache_read_input_token_cost": 3.3e-7, - "input_cost_per_token": 0.0000033, - "input_cost_per_token_above_200k_tokens": 0.0000066, - "output_cost_per_token_above_200k_tokens": 0.00002475, - "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, - "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "cache_creation_input_token_cost": 0.0000045, + "cache_creation_input_token_cost_above_1hr": 0.0000072, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, + "input_cost_per_token_above_200k_tokens": 0.0000072, + "output_cost_per_token_above_200k_tokens": 0.000027, + "cache_creation_input_token_cost_above_200k_tokens": 0.000009, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000144, + "cache_read_input_token_cost_above_200k_tokens": 7.2e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", - "output_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000018, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28891,6 +30726,39 @@ "cache_read_input_token_cost": 3e-7, "cache_creation_input_token_cost": 0.00000375 }, + "us.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000022, + "cache_read_input_token_cost": 0.0000011, + "input_cost_per_token": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000055, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, "cache_creation_input_token_cost_above_1hr": 0.0000022, @@ -29046,6 +30914,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -29070,6 +30939,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -29077,6 +30947,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -29912,7 +31783,8 @@ "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, - "supports_response_schema": true + "supports_response_schema": true, + "supports_image_size": false }, "vercel_ai_gateway/google/gemini-2.5-pro": { "input_cost_per_token": 0.0000025, @@ -30645,6 +32517,7 @@ }, "vertex_ai/claude-3-7-sonnet@20250219": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "deprecation_date": "2026-05-11", "input_cost_per_token": 0.000003, @@ -30742,8 +32615,69 @@ "supports_tool_choice": true, "supports_vision": true }, + "vertex_ai/claude-fable-5": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 0.000001, + "input_cost_per_token": 0.00001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-fable-5@default": { + "cache_creation_input_token_cost": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00002, + "cache_read_input_token_cost": 0.000001, + "input_cost_per_token": 0.00001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, "vertex_ai/claude-haiku-4-5": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "vertex_ai-anthropic_models", @@ -30765,6 +32699,7 @@ }, "vertex_ai/claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "vertex_ai-anthropic_models", @@ -30786,6 +32721,7 @@ }, "vertex_ai/claude-opus-4": { "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, "input_cost_per_token": 0.000015, "litellm_provider": "vertex_ai-anthropic_models", @@ -30811,6 +32747,7 @@ }, "vertex_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, "input_cost_per_token": 0.000015, "input_cost_per_token_batches": 0.0000075, @@ -30828,6 +32765,7 @@ }, "vertex_ai/claude-opus-4-1@20250805": { "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, "input_cost_per_token": 0.000015, "input_cost_per_token_batches": 0.0000075, @@ -30845,6 +32783,7 @@ }, "vertex_ai/claude-opus-4-5": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "vertex_ai-anthropic_models", @@ -30871,6 +32810,7 @@ }, "vertex_ai/claude-opus-4-5@20251101": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "vertex_ai-anthropic_models", @@ -30898,6 +32838,7 @@ }, "vertex_ai/claude-opus-4-6": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "vertex_ai-anthropic_models", @@ -30925,6 +32866,7 @@ }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "vertex_ai-anthropic_models", @@ -30952,6 +32894,7 @@ }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "vertex_ai-anthropic_models", @@ -30972,6 +32915,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -30979,6 +32923,7 @@ }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "vertex_ai-anthropic_models", @@ -30999,6 +32944,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -31020,6 +32966,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -31027,6 +32974,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -31048,6 +32996,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -31055,6 +33004,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -31062,6 +33012,7 @@ }, "vertex_ai/claude-opus-4@20250514": { "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, "cache_read_input_token_cost": 0.0000015, "input_cost_per_token": 0.000015, "litellm_provider": "vertex_ai-anthropic_models", @@ -31087,6 +33038,7 @@ }, "vertex_ai/claude-sonnet-4": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -31116,6 +33068,7 @@ }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -31142,6 +33095,7 @@ }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -31169,6 +33123,7 @@ }, "vertex_ai/claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "vertex_ai-anthropic_models", @@ -31196,6 +33151,7 @@ }, "vertex_ai/claude-sonnet-4-6@default": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "vertex_ai-anthropic_models", @@ -31223,6 +33179,7 @@ }, "vertex_ai/claude-sonnet-4@20250514": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, @@ -31408,7 +33365,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": false, - "tpm": 8000000 + "tpm": 8000000, + "supports_image_size": false }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-8, @@ -31837,6 +33795,22 @@ }, "web_search_billing_unit": "per_query" }, + "vertex_ai/google/gemma-4-26b-a4b-it-maas": { + "input_cost_per_token": 1.5e-7, + "litellm_provider": "vertex_ai-openai_models", + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/maas/google/gemma-4-26b-a4b-it", + "supported_regions": [ + "global" + ], + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, "vertex_ai/jamba-1.5": { "input_cost_per_token": 2e-7, "litellm_provider": "vertex_ai-ai21_models", @@ -33190,7 +35164,8 @@ "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-3-beta": { "cache_read_input_token_cost": 7.5e-7, @@ -33388,7 +35363,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-1-fast": { "cache_read_input_token_cost": 5e-8, @@ -33429,7 +35405,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-1-fast-non-reasoning-latest": { "cache_read_input_token_cost": 5e-8, @@ -33449,7 +35426,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-1-fast-reasoning": { "cache_read_input_token_cost": 5e-8, @@ -33470,7 +35448,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-1-fast-reasoning-latest": { "cache_read_input_token_cost": 5e-8, @@ -33491,7 +35470,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-fast-non-reasoning": { "cache_read_input_token_cost": 5e-8, @@ -33508,7 +35488,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-fast-reasoning": { "cache_read_input_token_cost": 5e-8, @@ -33525,7 +35506,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-latest": { "input_cost_per_token": 0.000003, @@ -33692,7 +35674,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2026-05-15" }, "xai/grok-code-fast-1-0825": { "cache_read_input_token_cost": 2e-8, @@ -33707,7 +35690,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2026-05-15" }, "xai/grok-vision-beta": { "input_cost_per_image": 0.000005, From aa584823415fb0315b463a370773ba5c4a7e4054 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 11:50:56 -0700 Subject: [PATCH 33/41] refactor: Add MCP server management guidance to agent coder Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 7 +++++-- cecli/website/docs/config/agent-mode.md | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 0538c909b00..ac217190196 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -627,7 +627,8 @@ def get_context_summary(self): if percentage > 80: result += "\n\n⚠ **Context is getting full!**\n" result += "- Remove non-essential files via the `ContextManager` tool.\n" - result += "- Keep only essential files in context for best performance" + result += "- Remove unused MCP servers via the `RemoveMcp` tool to free context space.\n" + result += "- Keep only essential files and MCP servers in context for best performance" result += "\n" if not hasattr(self, "context_blocks_cache"): self.context_blocks_cache = {} @@ -661,7 +662,9 @@ def get_environment_info(self): result += f"- Git repository: {rel_repo_dir} with {num_files:,} files\n" except Exception: result += "- Git repository: active but details unavailable\n" - else: + if self.mcp_manager and self.mcp_manager.connected_servers: + num_mcp_servers = len(self.mcp_manager.connected_servers) + result += f"- Connected MCP servers: {num_mcp_servers}\n" result += "- Git repository: none\n" result += "" return result diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index d66ac7c14e7..0b50f7c80ca 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -309,8 +309,26 @@ agent-config: For complete documentation on creating and using skills, including skill directory structure, SKILL.md format, and best practices, see the [Skills documentation](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/skills.md). +### MCP Server Management + +MCP (Model Context Protocol) servers provide external tools to the agent, but each connected server and its tools consume context tokens. To maintain optimal performance: + +- **Remove unused servers**: If an MCP server is no longer needed for the current task, remove it using the `RemoveMcp` tool to free up context space. +- **Load servers on demand**: Only load MCP servers when their tools are actually required. Use the `LoadMcp` tool to add servers as needed. +- **Monitor context usage**: The context summary block shows total token usage. Removing unnecessary MCP servers can significantly reduce context overhead. +- **List active servers**: Use the `ListMcp` tool to see which servers are currently connected and consuming context. + ### Benefits +### MCP Server Management +MCP (Model Context Protocol) servers provide external tools to the agent, but each connected server and its tools consume context tokens. To maintain optimal performance: + +- **Remove unused servers**: If an MCP server is no longer needed for the current task, remove it using the `RemoveMcp` tool to free up context space. +- **Load servers on demand**: Only load MCP servers when their tools are actually required. Use the `LoadMcp` tool to add servers as needed. +- **Monitor context usage**: The context summary block shows total token usage. Removing unnecessary MCP servers can significantly reduce context overhead. +- **List active servers**: Use the `ListMcp` tool to see which servers are currently connected and consuming context. + +### Benefits - **Autonomous operation**: Reduces need for manual file management - **Context awareness**: Real-time project information improves decision making - **Precision editing**: Granular tools reduce errors compared to SEARCH/REPLACE From 04ffd2f356a7f02a7702092bec456125f1b394dc Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 15:19:51 -0400 Subject: [PATCH 34/41] Lazy load litellm in mcp manager --- cecli/mcp/manager.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cecli/mcp/manager.py b/cecli/mcp/manager.py index ad50dc69bd3..4ee86d2496f 100644 --- a/cecli/mcp/manager.py +++ b/cecli/mcp/manager.py @@ -1,6 +1,5 @@ import asyncio -from cecli.llm import litellm from cecli.mcp.server import LocalServer, McpServer from cecli.tools.utils.registry import ToolRegistry @@ -112,6 +111,8 @@ async def connect_server(self, name: str) -> bool: Returns: Boolean indicating success or failure """ + from litellm import experimental_mcp_client + server = self.get_server(name) if not server: self._log_warning(f"MCP server not found: {name}") @@ -131,9 +132,7 @@ async def connect_server(self, name: str) -> bool: try: session = await server.connect() - tools = await litellm.experimental_mcp_client.load_mcp_tools( - session=session, format="openai" - ) + tools = await experimental_mcp_client.load_mcp_tools(session=session, format="openai") self._server_tools[server.name] = tools self._connected_servers.add(server) self._log_verbose(f"Connected to MCP server: {name}") From 32b2ef07cebb388dabfb2bd80cbead7c531ae43e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 15:24:20 -0400 Subject: [PATCH 35/41] Update documentation --- cecli/website/_includes/works-best.md | 2 +- cecli/website/_sass/global-overrides.scss | 2 +- cecli/website/assets/home.css | 122 +++++- cecli/website/docs/config.md | 2 +- cecli/website/docs/config/skills.md | 1 + cecli/website/docs/faq.md | 379 ------------------ cecli/website/docs/install.md | 59 +-- cecli/website/docs/llms.md | 27 +- cecli/website/docs/usage.md | 2 +- .../docs/{install => usage}/optional.md | 4 +- cecli/website/docs/usage/tips.md | 2 +- cecli/website/index.html | 17 +- cecli/website/install.sh | 2 + 13 files changed, 180 insertions(+), 441 deletions(-) delete mode 100644 cecli/website/docs/faq.md rename cecli/website/docs/{install => usage}/optional.md (98%) diff --git a/cecli/website/_includes/works-best.md b/cecli/website/_includes/works-best.md index 73a18008872..0d54f78495b 100644 --- a/cecli/website/_includes/works-best.md +++ b/cecli/website/_includes/works-best.md @@ -1 +1 @@ -cecli works best with Claude 3.5 Sonnet, DeepSeek R1 & Chat V3, OpenAI o1, o3-mini & GPT-4o. cecli can [connect to almost any LLM, including local models](https://cecli.chat/docs/llms.html). +cecli can [connect to almost any LLM, including local models](https://cecli.chat/docs/llms.html). diff --git a/cecli/website/_sass/global-overrides.scss b/cecli/website/_sass/global-overrides.scss index aa6d800a412..114a01bf357 100644 --- a/cecli/website/_sass/global-overrides.scss +++ b/cecli/website/_sass/global-overrides.scss @@ -17,7 +17,7 @@ // Grid layout for documentation pages on large screens display: grid !important; grid-template-columns: 300px 1fr; // Sidebar on left, main content on right - grid-template-rows: auto 1fr; // Top nav bar, then main content + grid-template-rows: 100vh; // Top nav bar, then main content grid-template-areas: "sidebar topnav" "sidebar main"; diff --git a/cecli/website/assets/home.css b/cecli/website/assets/home.css index a566f660836..b2dd68d5d70 100644 --- a/cecli/website/assets/home.css +++ b/cecli/website/assets/home.css @@ -289,6 +289,124 @@ nav { letter-spacing: -0.5px; } +/* ── Install Section ── */ +.install { + padding: 60px 0 40px; + text-align: center; +} + +.install .code-block { + margin: 0 auto; + position: relative; +} + +/* ── Install Grid (side-by-side cards) ── */ +.install-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + max-width: 800px; + margin: 0 auto; +} + +.install-card { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; + border-left: 3px solid var(--primary); +} + +.install-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 0.95rem; + font-weight: 600; + color: var(--dark); +} + +.install-card-header i { + font-size: 1.3rem; + color: var(--primary); +} + +.install-card .code-block { + margin: 0; + padding: 0.75rem 1rem; + font-size: 0.85rem; +} + +.install-card .code-block pre { + margin: 0; +} + +/* ── Copy Button (clickable
) ── */
+.copy-btn {
+    cursor: pointer !important;
+    user-select: none;
+    transition: background-color 0.3s, border-color 0.3s, transform 0.2s;
+    border: 1px solid transparent;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex: 1;
+    margin: 0;
+}
+
+.copy-btn:hover {
+    background-color: #2a2a2a !important;
+    border-color: var(--primary);
+    transform: translateY(-1px);
+}
+
+.copy-btn:active {
+    transform: translateY(0px);
+}
+
+.copy-btn:focus-visible {
+    outline: 2px solid var(--primary);
+    outline-offset: 2px;
+}
+
+.copy-btn code {
+    flex: 1;
+}
+
+.copy-btn i {
+    font-size: 1.2rem;
+    color: var(--gray);
+    transition: color 0.3s;
+    flex-shrink: 0;
+}
+
+.copy-btn:hover i {
+    color: white;
+}
+
+/* ── Install Note ── */
+.install-note {
+    margin-top: 1.5rem;
+    color: var(--gray);
+    font-size: 0.95rem;
+}
+
+.install-note a {
+    color: var(--primary);
+    text-decoration: none;
+    font-weight: 500;
+}
+
+.install-note a:hover {
+    text-decoration: underline;
+}
+
+@media (max-width: 768px) {
+    .install-grid {
+        grid-template-columns: 1fr;
+    }
+}
+
 .feature-grid {
     display: grid;
     grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
@@ -391,11 +509,11 @@ code, pre, .code-block {
 .code-block {
     background-color: var(--code-bg);
     border-radius: 8px;
-    padding: 1.5rem;
+    padding: 1rem;
     color: white;
     font-size: 1.1rem;
     line-height: 1.5;
-    margin: 1.5rem 0;
+    margin: 1rem 0;
     overflow-x: auto;
     box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
     tab-size: 2;
diff --git a/cecli/website/docs/config.md b/cecli/website/docs/config.md
index 55b983ffa47..1412b27f815 100644
--- a/cecli/website/docs/config.md
+++ b/cecli/website/docs/config.md
@@ -1,5 +1,5 @@
 ---
-nav_order: 55
+nav_order: 40
 has_children: true
 description: Information on all of cecli's settings and how to use them.
 ---
diff --git a/cecli/website/docs/config/skills.md b/cecli/website/docs/config/skills.md
index a53286097da..148e3bae0d4 100644
--- a/cecli/website/docs/config/skills.md
+++ b/cecli/website/docs/config/skills.md
@@ -3,6 +3,7 @@ parent: Configuration
 nav_order: 35
 description: Extend AI capabilities with custom instructions, reference materials, scripts, and assets through the skills system.
 ---
+
 # Skills System
 
 Agent Mode includes a powerful skills system that allows you to extend the AI's capabilities with custom instructions, reference materials, scripts, and assets. Skills are organized collections of knowledge and tools that help the AI perform specific tasks more effectively.
diff --git a/cecli/website/docs/faq.md b/cecli/website/docs/faq.md
deleted file mode 100644
index 71590935d44..00000000000
--- a/cecli/website/docs/faq.md
+++ /dev/null
@@ -1,379 +0,0 @@
----
-nav_order: 90
-description: Frequently asked questions about cecli.
----
-
-# FAQ
-{: .no_toc }
-
-- TOC
-{:toc}
-
-{% include help-tip.md %}
-
-## How can I add ALL the files to the chat?
-
-People regularly ask about how to add **many or all of their repo's files** to the chat.
-This is probably not a good idea and will likely do more harm than good.
-
-The best approach is think about which files need to be changed to accomplish
-the task you are working on. Just add those files to the chat.
-
-Usually when people want to add "all the files" it's because they think it
-will give the LLM helpful context about the overall code base.
-cecli will automatically give the LLM a bunch of additional context about
-the rest of your git repo.
-It does this by analyzing your entire codebase in light of the
-current chat to build a compact
-[repository map](https://cecli.dev/2023/10/22/repomap.html).
-
-Adding a bunch of files that are mostly irrelevant to the
-task at hand will often distract or confuse the LLM.
-The LLM will give worse coding results, and sometimese even fail to correctly edit files.
-Addings extra files will also increase your token costs.
-
-Again, it's usually best to just add the files to the chat that will need to be modified.
-If you still wish to add lots of files to the chat, you can:
-
-- Use a wildcard when you launch cecli: `cecli src/*.py`
-- Use a wildcard with the in-chat `/add` command: `/add src/*.py`
-- Give the `/add` command a directory name and it will recursively add every file under that dir: `/add src`
-
-## Can I use cecli in a large (mono) repo?
-
-cecli will work in any size repo, but is not optimized for quick
-performance and response time in very large repos.
-There are some things you can do to improve performance.
-
-Be sure to check the
-[general usage tips](/docs/usage/tips.html)
-before considering this large-repo specific advice.
-To get the best results from cecli you want to 
-be thoughtful about how you add files to the chat,
-regardless of your repo size.
-
-You can change into a sub directory of your repo that contains the
-code you want to work on and use the `--subtree-only` switch.
-This will tell cecli to ignore the repo outside of the
-directory you start in.
-
-You can also create a `.cecli.ignore` file to tell cecli
-to ignore parts of the repo that aren't relevant to your task.
-This file conforms to `.gitignore` syntax and conventions.
-For example, to focus only on specific directories in a monorepo,
-you could create a `.cecli.ignore` file with:
-
-```
-# Ignore everything
-/*
-
-# Allow specific directories and their contents
-!foo/
-!bar/
-!baz/
-
-# Allow nested files under these directories
-!foo/**
-!bar/**
-!baz/**
-```
-
-You can use `--cecli-ignore ` to name a specific file
-to use for ignore patterns.
-You might have a few of these handy for when you want to work on
-frontend, backend, etc portions of your repo.
-
-## Can I use cecli with multiple git repos at once?
-
-Currently cecli can only work with one repo at a time.
-
-There are some things you can try if you need to work with
-multiple interrelated repos:
-
-- You can run cecli in repo-A where you need to make a change
-and use `/read` to add some files read-only from another repo-B.
-This can let cecli see key functions or docs from the other repo.
-- You can run `cecli --show-repo-map > map.md` within each
-repo to create repo maps.
-You could then run cecli in repo-A and 
-use `/read ../path/to/repo-B/map.md` to share
-a high level map of the other repo.
-- You can use cecli to write documentation about a repo.
-Inside each repo, you could run `cecli docs.md`
-and work with cecli to write some markdown docs.
-Then while using cecli to edit repo-A
-you can `/read ../path/to/repo-B/docs.md` to
-read in those docs from the other repo.
-- In repo A, ask cecli to write a small script that demonstrates
-the functionality you want to use in repo B.
-Then when you're using cecli in repo B, you can 
-`/read` in that script.
-
-## How do I turn on the repository map?
-
-Depending on the LLM you are using, cecli may launch with the repo map disabled by default:
-
-```
-Repo-map: disabled
-```
-
-This is because weaker models get easily overwhelmed and confused by the content of the
-repo map. They sometimes mistakenly try to edit the code in the repo map.
-The repo map is usually disabled for a good reason.
-
-If you would like to force it on, you can run cecli with `--map-tokens 1024`.
-
-## How do I include the git history in the context?
-
-When starting a fresh cecli session, you can include recent git history in the chat context. This can be useful for providing the LLM with information about recent changes. To do this:
-
-1. Use the `/run` command with `git diff` to show recent changes:
-   ```
-   /run git diff HEAD~1
-   ```
-   This will include the diff of the last commit in the chat history.
-
-2. To include diffs from multiple commits, increase the number after the tilde:
-   ```
-   /run git diff HEAD~3
-   ```
-   This will show changes from the last three commits.
-
-Remember, the chat history already includes recent changes made during the current session, so this tip is most useful when starting a new cecli session and you want to provide context about recent work.
-
-You can also use cecli to review PR branches:
-
-```
-/run git diff one-branch..another-branch
-
-...
-
-Add 6.9k tokens of command output to the chat? (Y)es/(N)o [Yes]: Yes
-
-/ask Are there any problems with the way this change works with the FooBar class?
-```
-
-And of course you can prepare diff output outside of cecli and provide it as
-a file for cecli to read:
-
-```
-$ git diff -C10 v1..v2 > v1-v2-changes.diff
-$ cecli --read v1-v2-changes.diff
-
-cecli v0.77.2.dev+import
-Main model: anthropic/claude-3-7-sonnet-20250219 with diff edit format, 8k think tokens
-──────────────────────────────────
-v1-v2-changes.diff
-> Do you see any potential bugs in this PR?
-```
-
-
-{: .tip }
-The `/git` command will not work for this purpose, as its output is not included in the chat. 
-
-## How can I run cecli locally from source code?
-
-To run the project locally, follow these steps:
-
-```
-# Clone the repository
-git clone git@github.com:cecli-AI/cecli.git
-
-# Navigate to the project directory
-cd cecli
-
-# It's recommended to make a virtual environment
-
-# Install cecli in editable/development mode, 
-# so it runs from the latest copy of these source files
-python -m pip install -e .
-
-# Run the local version of cecli
-python -m cecli
-```
-
-
-
-## Can I change the system prompts that cecli uses?
-
-The most convenient way to add custom instructions is to use a
-[conventions file](https://cecli.dev/docs/usage/conventions.html).
-
-But, cecli is set up to support different actual system prompts and edit formats
-in a modular way. If you look in the `cecli/coders` subdirectory, you'll
-see there's a base coder with base prompts, and then there are
-a number of
-different specific coder implementations.
-
-If you're thinking about experimenting with system prompts
-this document about
-[benchmarking GPT-3.5 and GPT-4 on code editing](https://cecli.dev/docs/benchmarks.html)
-might be useful background.
-
-While it's not well documented how to add new coder subsystems, you may be able
-to modify an existing implementation or use it as a template to add another.
-
-To get started, try looking at and modifying these files.
-
-The wholefile coder is currently used by GPT-3.5 by default. You can manually select it with `--edit-format whole`.
-
-- wholefile_coder.py
-- wholefile_prompts.py
-
-The editblock coder is currently used by GPT-4o by default. You can manually select it with `--edit-format diff`.
-
-- editblock_coder.py
-- editblock_prompts.py
-
-The universal diff coder is currently used by GPT-4 Turbo by default. You can manually select it with `--edit-format udiff`.
-
-- udiff_coder.py
-- udiff_prompts.py
-
-When experimenting with coder backends, it helps to run cecli with `--verbose --no-pretty` so you can see
-all the raw information being sent to/from the LLM in the conversation.
-
-You can also refer to the
-[instructions for installing a development version of cecli](https://cecli.dev/docs/install/optional.html#install-the-development-version-of-cecli).
-
-## What LLMs do you use to build cecli?
-
-cecli writes a lot of its own code, usually about 70% of the new code in each
-release.
-People often ask which LLMs I use with cecli, when writing cecli.
-Below is a table showing the models I have used recently,
-extracted from the 
-[public log](https://github.com/dwash96/cecli/blob/main/cecli/website/assets/sample-analytics.jsonl)
-of my
-[cecli analytics](https://cecli.dev/docs/more/analytics.html).
-
-
-
-
-
-
-
-
-
-
-
-
-
Model NameTotal TokensPercent
gemini/gemini-2.5-pro222,04729.7%
gpt-5211,07228.2%
None168,98822.6%
gemini/gemini-3-pro-preview81,85111.0%
o3-pro36,6204.9%
gemini/gemini-2.5-flash-lite15,4702.1%
gemini/gemini-2.5-flash-lite-preview-06-1711,3711.5%
- - -## How are the "cecli wrote xx% of code" stats computed? - -[cecli is tightly integrated with git](/docs/git.html) so all -of cecli's code changes are committed to the repo with proper attribution. -The -[stats are computed](https://github.com/dwash96/cecli/blob/main/scripts/blame.py) -by doing something like `git blame` on the repo, -and counting up who wrote all the new lines of code in each release. -Only lines in source code files are counted, not documentation or prompt files. - -## Why did cecli ignore/discard its proposed edits after it asked to add a new file to the chat? - -If cecli prompts you to add a new file to the chat and you say yes, -it will re-submit the original request. -The fact that the LLM's reply indicated that it needed to see another file (and you said yes) -is often a sign that the LLM should have been able to see/edit that file in the first place. -Without access to it, there is increased chance that it's done a bad implementation of the requested change. -Often LLMs will hallucinate content for the files they needed but didn't have. -So cecli re-submits the original request in this situation. - -## Why does cecli sometimes stop highlighting code in its replies? - -cecli displays the markdown responses that are coming back from the LLM. -Usually, the LLM will reply with code in a markdown "code block" with -triple backtick fences, like this: - -```` -Here's some code: - -``` -print("hello") -``` -```` - -But if you've added files to the chat that contain triple backticks, -cecli needs to tell the LLM to use a different set of fences. -Otherwise, the LLM can't safely include your code's triple backticks -inside the code blocks that it returns with edits. -cecli will use fences like `...` in this case. - -A side effect of this is that the code that cecli outputs may no -longer be properly highlighted. -You will most often notice this if you add markdown files -to you chats that contain code blocks. - -## Why is the LLM speaking to me in an unexpected language? - -cecli goes to some effort to prompt the model to use the language that is configured -for your system. -But LLMs aren't fully reliable, and they sometimes decide to speak in -an unexpected language. -Claude is especially fond of speaking French. - -You can explicitly set the language that cecli tells the model to use with -`--chat-language `. -But the LLM may not comply. - -## Can I share my cecli chat transcript? - -Yes, you can now share cecli chat logs in a pretty way. - -1. Copy the markdown logs you want to share from `.cecli.chat.history.md` and make a github gist. Or publish the raw markdown logs on the web any way you'd like. - - ``` - https://gist.github.com/cecli-AI/2087ab8b64034a078c0a209440ac8be0 - ``` - -2. Take the gist URL and append it to: - - ``` - https://cecli.dev/share/?mdurl= - ``` - -This will give you a URL like this, which shows the chat history like you'd see in a terminal: - -``` -https://cecli.dev/share/?mdurl=https://gist.github.com/cecli-AI/2087ab8b64034a078c0a209440ac8be0 -``` - -## Can I edit files myself while cecli is running? - -Yes. cecli always reads the latest copy of files from the file -system when you send each message. - -While you're waiting for cecli's reply to complete, it's probably unwise to -edit files that you've added to the chat. -Your edits and cecli's edits might conflict. - -## What is cecli AI LLC? - -cecli AI LLC is the company behind the cecli AI coding tool. -cecli is -[open source and available on GitHub](https://github.com/dwash96/cecli) -under an -[Apache 2.0 license](https://github.com/dwash96/cecli/blob/main/LICENSE.txt). - -## Can I Script cecli? - -Yes. You can script cecli via the command line or python. See more from here: [Scripting cecli](https://cecli.dev/docs/scripting.html) - - -
- diff --git a/cecli/website/docs/install.md b/cecli/website/docs/install.md index 00656d3a351..0591eb9d36a 100644 --- a/cecli/website/docs/install.md +++ b/cecli/website/docs/install.md @@ -8,24 +8,6 @@ description: How to install and get started pair programming with cecli. # Installation {: .no_toc } - -## Get started quickly with uv - -We recommend using [uv](https://docs.astral.sh/uv/) for installing cecli as it provides the best experience and isolates cecli from your development environment. - -```bash -uv tool install --native-tls --python python3.12 cecli-dev -``` - -This will install cecli in its own separate python environment. -If needed, -uv will also install a separate version of python 3.12 to use with cecli (cecli supports Python 3.10-3.14). - -Once cecli is installed, -there are also some [optional install steps](/docs/install/optional.html). - -See the [usage instructions](https://cecli.dev/docs/usage.html) to start coding with cecli. - ## One-liners These one-liners will install cecli, along with python 3.12 if needed (cecli supports Python 3.10-3.14). @@ -52,7 +34,6 @@ wget -qO- https://cecli.dev/install.sh | sh powershell -ExecutionPolicy ByPass -c "irm https://cecli.dev/install.ps1 | iex" ``` - ## Install with uv You can install cecli with uv: @@ -106,17 +87,43 @@ or uv pip install --native-tls cecli-dev ``` -{% include python-m-aider.md %} +## Basic Configuration + +We highly recommend using an `.cecli.conf.yml` file in your project directories. A good place to get started is: + +```yaml +model: +agent: true +auto-commits: true +auto-save: true +cache-prompts: true +check-update: true +enable-context-compaction: true +context-compaction-max-tokens: 0.8 +show-model-warnings: true + +agent-config: + large_file_token_threshold: 8192 + skip_cli_confirmations: false + +mcp-servers: + mcpServers: + context7: + transport: http + url: https://mcp.context7.com/mcp +``` -#### Installing with package managers +### Run Program + +If you are in the directory with your .cecli.conf.yml file, then simply running `cecli` will start the agent with your configuration. For best results, since terminal emulators can be finicky, we highly suggest running: + +```bash +cecli --terminal-setup +``` -It's best to install cecli using one of methods -recommended above. -While cecli is available in a number of system package managers, -they often install cecli with incorrect dependencies. +On first run to configure keybindings for the program (notably `shift+enter`). Support for terminals is ongoing so feel free to make a github issue or chat in the discord for us to figure out what's needed to support automatically setting up a given terminal. ## Next steps... -There are some [optional install steps](/docs/install/optional.html) you could consider. See the [usage instructions](https://cecli.dev/docs/usage.html) to start coding with cecli. diff --git a/cecli/website/docs/llms.md b/cecli/website/docs/llms.md index 0dbb4b23ab6..cdb508182ac 100644 --- a/cecli/website/docs/llms.md +++ b/cecli/website/docs/llms.md @@ -1,25 +1,19 @@ --- title: Connecting to LLMs -nav_order: 40 +nav_order: 55 has_children: true description: cecli can connect to most LLMs for AI pair programming. --- -# cecli can connect to most LLMs -{: .no_toc } - -[![connecting to many LLMs](/assets/llms.jpg)](https://cecli.dev/assets/llms.jpg) - - -## Best models +## Recommended models {: .no_toc } cecli works best with these models, which are skilled at editing code: -- [Gemini 2.5 Pro](/docs/llms/gemini.html) -- [DeepSeek R1 and V3](/docs/llms/deepseek.html) -- [Claude 3.7 Sonnet](/docs/llms/anthropic.html) -- [OpenAI o3, o4-mini and GPT-4.1](/docs/llms/openai.html) +- [Gemini 3+](/docs/llms/gemini.html) +- [DeepSeek V4+](/docs/llms/deepseek.html) +- [Claude 4+](/docs/llms/anthropic.html) +- [GPT 5+](/docs/llms/openai.html) ## Free models @@ -28,12 +22,11 @@ cecli works best with these models, which are skilled at editing code: cecli works with a number of **free** API providers: - [OpenRouter offers free access to many models](https://openrouter.ai/models/?q=free), with limitations on daily usage. -- Google's [Gemini 2.5 Pro Exp](/docs/llms/gemini.html) works very well with cecli. ## Local models {: .no_toc } -cecli can work also with local models, for example using [Ollama](/docs/llms/ollama.html). +cecli can also work with local models, for example using [Ollama](/docs/llms/ollama.html). It can also access local models that provide an [Open AI compatible API](/docs/llms/openai-compat.html). @@ -41,14 +34,10 @@ local models that provide an ## Use a capable model {: .no_toc } -Check -[cecli's LLM leaderboards](https://cecli.dev/docs/leaderboards/) -to see which models work best with cecli. - Be aware that cecli may not work well with less capable models. If you see the model returning code, but cecli isn't able to edit your files and commit the changes... this is usually because the model isn't capable of properly returning "code edits". -Models weaker than GPT 3.5 may have problems working well with cecli. +Models weaker than GPT 4o may have problems working well with cecli. diff --git a/cecli/website/docs/usage.md b/cecli/website/docs/usage.md index eef6c97f0f4..549ad0c00dd 100644 --- a/cecli/website/docs/usage.md +++ b/cecli/website/docs/usage.md @@ -1,5 +1,5 @@ --- -nav_order: 30 +nav_order: 55 has_children: true description: How to use cecli to pair program with AI and edit code in your local git repo. --- diff --git a/cecli/website/docs/install/optional.md b/cecli/website/docs/usage/optional.md similarity index 98% rename from cecli/website/docs/install/optional.md rename to cecli/website/docs/usage/optional.md index 5833833423e..6aa97e574cd 100644 --- a/cecli/website/docs/install/optional.md +++ b/cecli/website/docs/usage/optional.md @@ -1,6 +1,6 @@ --- -parent: Installation -nav_order: 20 +parent: Usage +nav_order: 25 --- # Optional steps diff --git a/cecli/website/docs/usage/tips.md b/cecli/website/docs/usage/tips.md index 6e0fddd9e3b..b1440c9c22a 100644 --- a/cecli/website/docs/usage/tips.md +++ b/cecli/website/docs/usage/tips.md @@ -1,6 +1,6 @@ --- parent: Usage -nav_order: 25 +nav_order: 20 description: Tips for AI pair programming with cecli. --- diff --git a/cecli/website/index.html b/cecli/website/index.html index 07f969985f8..00a92d0e563 100644 --- a/cecli/website/index.html +++ b/cecli/website/index.html @@ -37,15 +37,16 @@
-

Oh, to live in a terminal

+

An ode to the CLI

- Cecli wears many hats:
- A pair programmer, a researcher, a writer, - a general automaton and most fundamentally, an assistant + Here, we spoke to the machine
+ Now, the machine speaks back
+ So we journey, together

@@ -65,7 +66,7 @@

Cloud and local LLMs

-

cecli works best with Claude 4.5 Sonnet, DeepSeek Chat V3, OpenAI GPT-5.2 & Gemini 3, but can connect to almost any LLM, including local models.

+

cecli works best with Claude 4.5 Sonnet, DeepSeek V4, OpenAI GPT-5.2 & Gemini 3, but can connect to almost any LLM, including local models.

-

Community & Resources

+

Community & Resources

Connect with other users and find additional resources

  • LLM Leaderboards
  • diff --git a/cecli/website/install.sh b/cecli/website/install.sh index 38b94a011ad..e64a6511f01 100644 --- a/cecli/website/install.sh +++ b/cecli/website/install.sh @@ -1829,4 +1829,6 @@ verify_checksum() { fi } + +# Run the installer download_binary_and_run_installer "$@" || exit 1 From 91c9f4d4ad64f0253d0fdadc413fa8e2073e9a24 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 18:30:38 -0400 Subject: [PATCH 36/41] Compact file and diff messages on compaction pressure --- cecli/coders/base_coder.py | 12 ++++++++++++ cecli/helpers/conversation/integration.py | 8 ++++---- .../helpers/observations/test_observation_service.py | 8 ++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 9c2a26d69d2..ceeb4862a3d 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1991,13 +1991,25 @@ async def compact_context_if_needed(self, force=False, message=""): done_messages = manager.get_messages_dict(MessageTag.DONE) cur_messages = manager.get_messages_dict(MessageTag.CUR) diff_messages = manager.get_messages_dict(MessageTag.DIFFS) + all_messages = manager.get_messages_dict() # Exclude first cur_message since that's the user's initial input done_tokens = self.summarizer.count_tokens(done_messages) cur_tokens = self.summarizer.count_tokens(cur_messages[1:] if len(cur_messages) > 1 else []) diff_tokens = self.summarizer.count_tokens(diff_messages) + all_tokens = self.summarizer.count_tokens(all_messages) + combined_tokens = done_tokens + cur_tokens + diff_tokens + if force or ( + all_tokens >= self.context_compaction_max_tokens * 0.9 + and ConversationService.get_chunks(self).last_clear_count > 10 + ): + manager.clear_tag(MessageTag.DIFFS) + manager.clear_tag(MessageTag.FILE_CONTEXTS) + ConversationService.get_files(self).clear_file_cache() + ConversationService.get_chunks(self).flush_removals() + if not force and combined_tokens < self.context_compaction_max_tokens: return diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index a245f50df64..8c46101d2eb 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -18,7 +18,7 @@ class ConversationChunks: def __init__(self, coder): self.coder = weakref.ref(coder) self.uuid = coder.uuid - self._last_clear_count = 0 + self.last_clear_count = 0 self._deferred_removals = set() @classmethod @@ -282,14 +282,14 @@ def cleanup_files(self) -> None: if diff_count > 0 and other_count > 0 and diff_count / other_count > 20: should_clear = True - self._last_clear_count += 1 + self.last_clear_count += 1 if ( should_clear - and self._last_clear_count >= 20 + and self.last_clear_count >= 20 and diff_tokens + other_tokens > coder.context_compaction_max_tokens * 0.5 ): - self._last_clear_count = 0 + self.last_clear_count = 0 # Clear all diff messages ConversationService.get_manager(coder).clear_tag(MessageTag.DIFFS) diff --git a/tests/helpers/observations/test_observation_service.py b/tests/helpers/observations/test_observation_service.py index d51b8087da8..b456e06c2a6 100644 --- a/tests/helpers/observations/test_observation_service.py +++ b/tests/helpers/observations/test_observation_service.py @@ -93,8 +93,8 @@ async def test_compact_context_with_observations(): # 2. compact (DONE) # 3. compact (CUR) # 4. compact (DIFFS) - mock_conv_manager.get_messages_dict.side_effect = [cur_messages, [], cur_messages, []] - + # 5. compact (ALL) + mock_conv_manager.get_messages_dict.side_effect = [cur_messages, [], cur_messages, [], []] with patch( "cecli.coders.base_coder.ConversationService.get_manager", return_value=mock_conv_manager ): @@ -154,8 +154,8 @@ async def test_compact_context_with_observations_integration(): # 2. compact (DONE) # 3. compact (CUR) # 4. compact (DIFFS) - mock_conv_manager.get_messages_dict.side_effect = [cur_messages, [], cur_messages, []] - + # 5. compact (ALL) + mock_conv_manager.get_messages_dict.side_effect = [cur_messages, [], cur_messages, [], []] with patch( "cecli.coders.base_coder.ConversationService.get_manager", return_value=mock_conv_manager ): From 9d4d0c64c5ca48fd67605642dccce56a0df99e94 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 18:43:15 -0400 Subject: [PATCH 37/41] Remove extraneous coder class changes --- cecli/coders/agent_coder.py | 18 ++++++------------ cecli/coders/base_coder.py | 2 -- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index ac217190196..fcf36479691 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -32,7 +32,7 @@ from .base_coder import Coder -from cecli.helpers import coroutines # isort:skip +from cecli.helpers.coroutines import interruptible # isort:skip logger = logging.getLogger(__name__) @@ -110,7 +110,7 @@ def __init__(self, *args, **kwargs): def post_init(self): super().post_init() - self.coroutines = coroutines + if not self._inherited_tools: # Populate per-instance tool and server filters from config self.registered_tools["included"] = set( @@ -325,9 +325,7 @@ async def _exec_async(): self.io.tool_warning(f"Executing {tool_name} on {server.name} failed:\nError: {e}") return f"Error executing tool call {tool_name}: {e}" - result, interrupted = await self.coroutines.interruptible( - _exec_async(), self.interrupt_event - ) + result, interrupted = await interruptible(_exec_async(), self.interrupt_event) if interrupted: return "Tool execution interrupted by user." @@ -771,7 +769,7 @@ async def _execute_local_tools(self, tool_calls_list): async def gather_and_await(): return await asyncio.gather(*tasks, return_exceptions=True) - task_results, interrupted = await self.coroutines.interruptible( + task_results, interrupted = await interruptible( gather_and_await(), self.interrupt_event ) @@ -811,9 +809,7 @@ async def gather_and_await(): if self.auto_lint and used_write_tool: edited = list(self.files_edited_by_tools) lint_coro = self.lint_edited(edited, show_output=False) - lint_errors, interrupted = await self.coroutines.interruptible( - lint_coro, self.interrupt_event - ) + lint_errors, interrupted = await interruptible(lint_coro, self.interrupt_event) if interrupted: raise KeyboardInterrupt("Interrupted during linting") @@ -933,9 +929,7 @@ async def reply_completed(self): ) self.io.tool_output(waiting_msg) sleep_coro = asyncio.sleep(command_timeout / 2) - _res, interrupted = await self.coroutines.interruptible( - sleep_coro, self.interrupt_event - ) + _res, interrupted = await interruptible(sleep_coro, self.interrupt_event) if interrupted: raise KeyboardInterrupt("Interrupted while waiting for background commands") return True diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 6e4a21653ec..ceeb4862a3d 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -436,11 +436,9 @@ def __init__( registered_servers=None, uuid: str = "", parent_uuid: str = "", - **kwargs, ): from cecli.helpers.agents.service import AgentService - self.original_kwargs = kwargs # initialize from args.map_cache_dir self.coroutines = coroutines # Per-instance tool and server filtering dictionaries From f40f18228660a07e0ce0810870ed43b0843e392e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 19:02:13 -0400 Subject: [PATCH 38/41] Make `/list-mcp` sub agent sensitive --- cecli/commands/list_mcp.py | 39 ++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/cecli/commands/list_mcp.py b/cecli/commands/list_mcp.py index 20457590e90..0cf14c56446 100644 --- a/cecli/commands/list_mcp.py +++ b/cecli/commands/list_mcp.py @@ -15,21 +15,44 @@ async def execute(cls, io, coder, args, **kwargs): all_servers = coder.mcp_manager.servers connected_servers = coder.mcp_manager.connected_servers - loaded_server_names = {server.name for server in connected_servers} + connected_server_names = {s.name for s in connected_servers} + + # Apply per-coder registered_servers filtering to determine active status + incl = coder.registered_servers["included"] + excl = coder.registered_servers["excluded"] + + active_servers = [] + inactive_servers = [] + + for server in connected_servers: + name = server.name + # Same filtering logic used in base_coder.get_tool_list() + if incl and name not in incl: + inactive_servers.append(name) + elif name in excl: + inactive_servers.append(name) + else: + active_servers.append(name) + configured_servers = [ - server for server in all_servers if server.name not in loaded_server_names + server for server in all_servers if server.name not in connected_server_names ] result = [] - if loaded_server_names: - result.append("Loaded MCP Servers:") - for name in sorted(list(loaded_server_names)): + if active_servers: + result.append("Active MCP Servers:") + for name in sorted(active_servers): result.append(f"- {name}") else: - result.append("No MCP servers are currently loaded.") + result.append("No MCP servers are active for this coder.") - result.append("") + if inactive_servers: + result.append("") + result.append("Inactive (Filtered) MCP Servers:") + for name in sorted(inactive_servers): + result.append(f"- {name}") + result.append("") if configured_servers: result.append("Configured MCP Servers:") for server in sorted(configured_servers, key=lambda s: s.name): @@ -44,5 +67,5 @@ def get_help(cls) -> str: """Get help text for the list-mcp command.""" help_text = super().get_help() help_text += "\nUsage:\n" - help_text += " /list-mcp # Lists all loaded and configured MCP servers\n" + help_text += " /list-mcp # Lists MCP servers with coder-sensitive active/inactive/configured status\n" return help_text From 46fd6225401e73e66ea5b28c634540e774e3b6db Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 21 Jun 2026 19:20:36 -0400 Subject: [PATCH 39/41] Make load and remove MCP tools match their command counterparts --- cecli/tools/load_mcp.py | 31 ++++++++++++++++++++++++-- cecli/tools/remove_mcp.py | 46 +++++++++++++++++++++++++++------------ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/cecli/tools/load_mcp.py b/cecli/tools/load_mcp.py index fb98f55811d..1defb166587 100644 --- a/cecli/tools/load_mcp.py +++ b/cecli/tools/load_mcp.py @@ -1,5 +1,9 @@ from typing import List +from cecli.commands.utils.helpers import ( + iter_all_coders, + update_server_registration, +) from cecli.tools.utils.base_tool import BaseTool @@ -35,7 +39,7 @@ async def execute(cls, coder, servers: List[str]): if servers == ["*"]: for server in coder.mcp_manager.servers: - if server.name in coder.mcp_manager.connected_servers: + if server.name in {s.name for s in coder.mcp_manager.connected_servers}: results.append(f"Server already loaded: {server.name}") continue auto_connect = server.config.get("enabled", True) @@ -54,10 +58,24 @@ async def execute(cls, coder, servers: List[str]): if not servers_to_load and results: return "\n".join(results) + # Before connecting any new server, convert coders with empty included sets + # to explicit include lists of all currently connected MCP servers. + # This moves them from "implicitly include all" to explicit state-machine + # management, preventing the new server from being implicitly available + # to all coders. + connected_names = {s.name for s in coder.mcp_manager.connected_servers} + if connected_names: + for c in iter_all_coders(coder): + if not c.registered_servers["included"]: + included = set(connected_names) - c.registered_servers["excluded"] + if c.edit_format in ("agent", "subagent"): + included.add("Local") # "local" is always available + c.registered_servers["included"] = included + # Process the loading for server in servers_to_load: server_name = server.name - if server_name in coder.mcp_manager.connected_servers: + if server_name in {s.name for s in coder.mcp_manager.connected_servers}: results.append(f"Server already loaded: {server_name}") continue @@ -71,6 +89,15 @@ async def execute(cls, coder, servers: List[str]): results.append(f"Interrupted: {server_name}") continue if did_connect: + # Force-include on the primary (active) coder + update_server_registration(coder, server_name, "include", force=True) + + # Safe-exclude on all other coders (respects existing inclusions) + for other_coder in iter_all_coders(coder): + if other_coder is coder: + continue + update_server_registration(other_coder, server_name, "exclude", force=False) + results.append(f"Loaded server: {server_name}") else: results.append(f"Unable to load server: {server_name}") diff --git a/cecli/tools/remove_mcp.py b/cecli/tools/remove_mcp.py index 788647de8c6..3355692b6bd 100644 --- a/cecli/tools/remove_mcp.py +++ b/cecli/tools/remove_mcp.py @@ -1,5 +1,9 @@ from typing import List +from cecli.commands.utils.helpers import ( + is_server_globally_excluded, + update_server_registration, +) from cecli.tools.utils.base_tool import BaseTool @@ -39,13 +43,13 @@ async def execute(cls, coder, servers: List[str]): # Determine which servers to act on if servers == ["*"]: - servers_to_action.extend(coder.mcp_manager.connected_servers.keys()) + servers_to_action.extend(s.name for s in coder.mcp_manager.connected_servers) else: for server_name in servers: server = coder.mcp_manager.get_server(server_name) if not server: results.append(f"MCP server {server_name} does not exist.") - elif server.name not in coder.mcp_manager.connected_servers: + elif server.name not in {s.name for s in coder.mcp_manager.connected_servers}: results.append(f"Server {server_name} is not currently connected.") else: servers_to_action.append(server.name) @@ -58,20 +62,34 @@ async def execute(cls, coder, servers: List[str]): if not servers_to_action: return "No servers to remove." - # Process the removal + # Process the removal with server registration awareness for server_name in servers_to_action: - coder.interrupt_event.clear() - did_disconnect, interrupted = await coder.coroutines.interruptible( - coder.mcp_manager.disconnect_server(server_name), - coder.interrupt_event, - ) - - if interrupted: - results.append(f"Interrupted: {server_name}") + # Never remove the "local" server + if server_name == "Local": + results.append("Cannot remove 'Local' server") continue - if did_disconnect: - results.append(f"Removed server: {server_name}") + + # Force-exclude on the primary (active) coder + update_server_registration(coder, server_name, "exclude", force=True) + + # Check if all coders in the hierarchy have this server excluded + all_excluded = is_server_globally_excluded(coder, server_name) + + if all_excluded: + coder.interrupt_event.clear() + did_disconnect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.disconnect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + results.append(f"Interrupted: {server_name}") + continue + if did_disconnect: + results.append(f"Removed server: {server_name}") + else: + results.append(f"Unable to remove server: {server_name}") else: - results.append(f"Unable to remove server: {server_name}") + results.append(f"Removed from active coder, still active for others: {server_name}") return "\n".join(results) From 4e60699769dfec463523734a0f1823d2d324d204 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Jun 2026 00:18:42 -0400 Subject: [PATCH 40/41] Move MCP and skill management to common `ResourceManager` tool --- cecli/coders/agent_coder.py | 86 ++- cecli/helpers/conversation/integration.py | 6 +- cecli/prompts/agent.yml | 6 +- cecli/prompts/subagent.yml | 4 +- cecli/tools/__init__.py | 14 +- cecli/tools/command.py | 8 +- cecli/tools/context_manager.py | 308 --------- cecli/tools/list_mcp.py | 50 -- cecli/tools/load_mcp.py | 105 --- cecli/tools/load_skill.py | 48 -- cecli/tools/read_range.py | 2 +- cecli/tools/remove_mcp.py | 95 --- cecli/tools/remove_skill.py | 48 -- cecli/tools/resource_manager.py | 617 ++++++++++++++++++ cecli/tools/utils/helpers.py | 4 +- cecli/tools/utils/registry.py | 2 +- cecli/website/docs/config/agent-mode.md | 26 +- .../docs/config/custom-system-prompts.md | 2 +- pytest.ini | 1 + .../integration/test_agent_mcp_management.py | 15 +- tests/integration/test_mcp_management.py | 17 +- tests/tools/test_registry.py | 73 ++- tests/tools/test_remove_mcp_tool.py | 31 +- tests/tools/test_tools_load_mcp_tool.py | 38 +- tests/unit/test_load_mcp.py | 45 +- tests/unit/test_remove_mcp.py | 56 +- tests/unit/test_unit_load_mcp_tool.py | 25 +- tests/unit/test_unit_remove_mcp_tool.py | 55 +- 28 files changed, 923 insertions(+), 864 deletions(-) delete mode 100644 cecli/tools/context_manager.py delete mode 100644 cecli/tools/list_mcp.py delete mode 100644 cecli/tools/load_mcp.py delete mode 100644 cecli/tools/load_skill.py delete mode 100644 cecli/tools/remove_mcp.py delete mode 100644 cecli/tools/remove_skill.py create mode 100644 cecli/tools/resource_manager.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index fcf36479691..94d61738442 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -194,6 +194,7 @@ def _get_agent_config(self): "todo_list", "sub_agents", "skills", + "servers", }, ) ) @@ -357,6 +358,7 @@ def _calculate_context_block_tokens(self, force=False): "git_status", "symbol_outline", "skills", + "servers", "sub_agents", "loaded_skills", ] @@ -391,6 +393,8 @@ def _generate_context_block(self, block_name): content = self.get_todo_list() elif block_name == "skills": content = self.get_skills_context() + elif block_name == "servers": + content = self.get_servers_context() elif block_name == "loaded_skills": content = self.get_skills_content() elif block_name == "sub_agents" and ( @@ -624,9 +628,8 @@ def get_context_summary(self): result += f" ({percentage:.1f}% of limit)" if percentage > 80: result += "\n\n⚠ **Context is getting full!**\n" - result += "- Remove non-essential files via the `ContextManager` tool.\n" - result += "- Remove unused MCP servers via the `RemoveMcp` tool to free context space.\n" - result += "- Keep only essential files and MCP servers in context for best performance" + result += "- Remove non-essential files, skills and tool servers via the `ResourceManager` tool.\n" + result += "- Keep only essential files, skills, and MCP servers in context for best performance" result += "\n" if not hasattr(self, "context_blocks_cache"): self.context_blocks_cache = {} @@ -1328,7 +1331,7 @@ async def check_for_file_mentions(self, content): Override parent's method to disable implicit file mention handling in agent mode. Files should only be added via explicit tool commands - (`ContextManager`). + (`ResourceManager`). """ pass @@ -1482,6 +1485,81 @@ def get_skills_content(self): self.io.tool_error(f"Error generating skills content context: {str(e)}") return None + def get_servers_context(self): + """ + Generate a context block for available MCP servers. + + Categorizes servers as: + - Active: Connected and passing includelist/excludelist filters + - Inactive: Connected but filtered out by includelist/excludelist + - Available (Disconnected): Managed but not currently connected + + Returns: + Formatted context block string or None if no servers available + """ + if not self.use_enhanced_context: + return None + try: + if not self.mcp_manager: + return None + + all_servers = self.mcp_manager.servers + connected_servers = self.mcp_manager.connected_servers + connected_server_names = {s.name for s in connected_servers} + + if not all_servers: + return None + + # Apply registered_servers filtering to determine active vs inactive + incl = self.registered_servers.get("included", set()) + excl = self.registered_servers.get("excluded", set()) + + active_servers = [] + inactive_servers = [] + for server in connected_servers: + name = server.name + if incl and name not in incl: + inactive_servers.append(name) + elif name in excl: + inactive_servers.append(name) + else: + active_servers.append(name) + + # Servers managed but not currently connected + disconnected_servers = [ + server.name for server in all_servers if server.name not in connected_server_names + ] + + result = '\n' + result += "## Connected MCP Servers\n\n" + + if active_servers: + result += f"Active ({len(active_servers)}):\n" + for name in sorted(active_servers): + result += f"- {name}\n" + result += "\n" + + if inactive_servers: + result += f"Inactive (Filtered) ({len(inactive_servers)}):\n" + for name in sorted(inactive_servers): + result += f"- {name}\n" + result += "\n" + + if disconnected_servers: + result += f"Available (Disconnected) ({len(disconnected_servers)}):\n" + for name in sorted(disconnected_servers): + result += f"- {name}\n" + result += "\n" + + if not active_servers and not inactive_servers and not disconnected_servers: + result += "No MCP servers currently available.\n\n" + + result += "" + return result + except Exception as e: + self.io.tool_error(f"Error generating servers context: {str(e)}") + return None + def get_sub_agents_context(self): """ Generate a context block for registered sub-agents. diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 8c46101d2eb..a9381d4ecbb 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -890,7 +890,7 @@ def add_static_context_blocks(self) -> None: """ Add static context blocks to conversation (priority 50). - Static blocks include: environment_info, directory_structure, skills + Static blocks include: environment_info, directory_structure, skills, servers, sub_agents """ coder = self.get_coder() if not coder: @@ -922,6 +922,10 @@ def add_static_context_blocks(self) -> None: block = coder._generate_context_block("skills") if block: message_blocks["skills"] = block + if "servers" in coder.allowed_context_blocks: + block = coder._generate_context_block("servers") + if block: + message_blocks["servers"] = block # Add static blocks to conversation manager with stable hash keys for block_type, block_content in message_blocks.items(): diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 3b2356d94d9..ec1473e3f41 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -42,7 +42,7 @@ main_system: | ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. - 3. **Execute**: Mark files as editable with `ContextManager` before attempting edits. Proactively use skills if they are available. Review diff outputs after edit to ensure the proper changes were made. + 3. **Execute**: Mark files as editable with `ResourceManager` before attempting edits. Proactively use skills if they are available. Review diff outputs after edit to ensure the proper changes were made. 4. **Verify & Recover**: If an edit fails or introduces linting errors, fix the error immediately. Use `UndoChange` if the errors are too complex to incrementally modify. 5. **Yield**: Use the `Yield` tool after accomplishing the goal and verifying any changes made. Provide helpful summaries of any changes. @@ -52,7 +52,7 @@ main_system: | ## Operational Rules - **Scope**: No unrequested refactors. Avoid full-file rewrites. Only modify what you are asked to. - - **Hygiene**: Use `ContextManager`/`RemoveSkill` to evict unneeded files/skills immediately after use. + - **Hygiene**: Use `ResourceManager` to evict unneeded files/skills immediately after use. - **Outputs**: Tool calls trigger turns. Never include tool syntax in final user summaries. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. - **Responses**: Reason out loud through the problem but be brief. @@ -71,7 +71,7 @@ system_reminder: | {lazy_prompt} {shell_cmd_reminder} - "" + try_again: | My previous exploration was insufficient. I will now adjust my strategy, use more specific search patterns, and manage my context more aggressively to find the correct solution. \ No newline at end of file diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index fd6f2754ac7..6e3867a9852 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -27,7 +27,7 @@ main_system: | ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Modify search terms when errors are encountered. - 3. **Execute**: Mark files as editable with `ContextManager` before attempting edits. Proactively use skills if they are available. Review diff outputs after edit to ensure the proper changes were made. + 3. **Execute**: Mark files as editable with `ResourceManager` before attempting edits. Proactively use skills if they are available. Review diff outputs after edit to ensure the proper changes were made. 4. **Verify & Recover**: If an edit fails or introduces linting errors, fix the error immediately. Use `UndoChange` if the errors are too complex to incrementally modify. 5. **Yield**: Use the `Yield` tool after accomplishing the goal and verifying any changes made. Provide helpful summaries of any changes. @@ -37,7 +37,7 @@ main_system: | ## Operational Rules - **Scope**: No unrequested refactors. Avoid full-file rewrites. Only modify what you are asked to. - - **Hygiene**: Use `ContextManager`/`RemoveSkill` to evict unneeded files/skills immediately after use. + - **Hygiene**: Use `ResourceManager` to evict unneeded files/skills immediately after use. - **Outputs**: Tool calls trigger turns. Never include tool syntax in final user summaries. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. - **Responses**: Reason out loud through the problem but be brief. diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 54dd6622227..4791865349d 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -6,7 +6,6 @@ _yield, command, command_interactive, - context_manager, delegate, edit_text, explore_code, @@ -17,13 +16,9 @@ git_show, git_status, grep, - list_mcp, - load_mcp, - load_skill, ls, read_range, - remove_mcp, - remove_skill, + resource_manager, thinking, undo_change, update_todo_list, @@ -33,7 +28,6 @@ TOOL_MODULES = [ command, command_interactive, - context_manager, delegate, edit_text, explore_code, @@ -45,13 +39,9 @@ git_show, git_status, grep, - list_mcp, - load_mcp, - load_skill, ls, read_range, - remove_mcp, - remove_skill, + resource_manager, thinking, undo_change, update_todo_list, diff --git a/cecli/tools/command.py b/cecli/tools/command.py index bf154004da0..646fd46d5ba 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -310,8 +310,8 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal output_content = ( f"[Large Response ({total_size} characters). " "Output saved to paginated files.]\n" - f"File Aliases (for use with ContextManager):\n{alias_list_str}\n" - "Use the `ContextManager` tool to view these files." + f"File Aliases (for use with ResourceManager):\n{alias_list_str}\n" + "Use the `ResourceManager` tool to view these files." "Do not use standard cli tools to view these files." "Remove them from context after taking notes on the relevant information " "to prevent overfilling stale context." @@ -397,8 +397,8 @@ async def _execute_foreground(cls, coder, command_string): output_content = ( f"[Large Response ({total_size} characters). " "Output saved to paginated files.]\n" - f"File Aliases (for use with ContextManager):\n{alias_list_str}\n" - "Use the `ContextManager` tool to view these files." + f"File Aliases (for use with ResourceManager):\n{alias_list_str}\n" + "Use the `ResourceManager` tool to view these files." "Do not use standard cli tools to view these files." "Remove them from context after taking note of the relevant information " "in the output to prevent overfilling stale context." diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py deleted file mode 100644 index c5883f97e16..00000000000 --- a/cecli/tools/context_manager.py +++ /dev/null @@ -1,308 +0,0 @@ -import os -import re -import time - -from cecli.helpers.background_commands import BackgroundCommandManager -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ToolError, parse_arg_as_list -from cecli.tools.utils.output import color_markers, tool_footer, tool_header -from cecli.tools.validations import ToolValidations - - -class Tool(BaseTool): - NORM_NAME = "contextmanager" - SCHEMA = { - "type": "function", - "function": { - "name": "ContextManager", - "description": ( - "Manage multiple files in the chat context: add, read_only, create, and remove." - " Accepts arrays of file paths for each operation." - ), - "parameters": { - "type": "object", - "properties": { - "add": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "List of file paths to add to context. Limit to at most 2 at a time." - ), - }, - "read_only": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "List of file paths to add as read-only. Limit to at most 2 at a time." - ), - }, - "create": { - "type": "array", - "items": {"type": "string"}, - "description": "List of file paths to create.", - }, - "remove": { - "type": "array", - "items": {"type": "string"}, - "description": "List of file paths to remove from context.", - }, - "stop": { - "type": "array", - "items": {"type": "string"}, - "description": "List of command keys to stop background commands for.", - }, - }, - "additionalProperties": False, - "required": [], - }, - }, - } - - @classmethod - def execute( - cls, coder, remove=None, add=None, read_only=None, create=None, stop=None, **kwargs - ): - """Perform batch operations on the coder's context. - - Parameters - ---------- - coder: Coder instance - The active coder handling file context. - remove: list[str] | None - Files to remove from the context. - add: list[str] | None - Files to promote to editable status. - view: list[str] | None - Files to add as read-only view. - create: list[str] | None - Files to create and make editable. - """ - remove_files = sorted(parse_arg_as_list(remove), key=cls._natural_sort_key) - editable_files = sorted(parse_arg_as_list(add), key=cls._natural_sort_key) - view_files = sorted(parse_arg_as_list(read_only), key=cls._natural_sort_key) - create_files = sorted(parse_arg_as_list(create), key=cls._natural_sort_key) - stop_keys = sorted(parse_arg_as_list(stop), key=cls._natural_sort_key) - - if ( - not remove_files - and not editable_files - and not view_files - and not create_files - and not stop_keys - ): - raise ToolError( - "You must specify at least one of: remove, editable, view, create, or stop" - ) - - coder.io.tool_output("⛭ Modifying Context", type="tool-result") - messages = [] - - for f in create_files: - messages.append(cls._create(coder, f)) - for f in remove_files: - messages.append(cls._remove(coder, f)) - for f in view_files: - messages.append(cls._view(coder, f)) - for f in editable_files: - messages.append(cls._editable(coder, f)) - for key in stop_keys: - messages.append(cls._stop_command(coder, key)) - - if coder.tui and coder.tui(): - coder.tui().refresh() - - coder.context_blocks_cache = {} - coder.edit_allowed = True - - return "\n".join(messages) - - @classmethod - def format_output(cls, coder, mcp_server, tool_response): - """Format output for ContextManager tool.""" - color_start, color_end = color_markers(coder) - - # Output header - tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) - - try: - params = ToolValidations.validate_params( - tool_response.function.arguments, cls.VALIDATIONS, cls.SCHEMA - ) - except ToolError: - coder.io.tool_error("Invalid Tool JSON") - return - - # Define action display names - action_names = { - "create": "create", - "remove": "remove", - "view": "view", - "editable": "editable", - "stop": "stop", - } - - # Output each action with comma-separated file list - for action_key, display_name in action_names.items(): - files = sorted(parse_arg_as_list(params.get(action_key)), key=cls._natural_sort_key) - if files: - file_list = ", ".join(files) - coder.io.tool_output(f"{color_start}{display_name}:{color_end} {file_list}") - - tool_footer(coder=coder, tool_response=tool_response, params=params) - - @classmethod - def _remove(cls, coder, file_path): - """Remove a file from the coder's context.""" - from cecli.helpers.conversation import ConversationService - - try: - abs_path = cls._resolve_file_path(coder, file_path) - rel_path = coder.get_rel_fname(abs_path) - removed = False - - if abs_path in coder.abs_fnames: - coder.abs_fnames.remove(abs_path) - removed = True - - if abs_path in coder.abs_read_only_fnames: - coder.abs_read_only_fnames.remove(abs_path) - removed = True - - if not removed: - coder.io.tool_output(f"⚠ File '{file_path}' not in context", type="tool-result") - return f"File not in context: {file_path}" - - coder.recently_removed[rel_path] = {"removed_at": time.time()} - - if not file_path.startswith("command_key::"): - ConversationService.get_chunks(coder).defer_removal(abs_path) - ConversationService.get_chunks(coder).defer_removal(rel_path) - - coder.io.tool_output(f"✗ Removed '{file_path}' from context", type="tool-result") - return ( - f"Removed: {file_path}\n" - "Old file contents may remain visible. This is an acceptable system behavior." - ) - except Exception as e: - coder.io.tool_error(f"Error removing file '{file_path}': {str(e)}") - return f"Error removing {file_path}: {e}" - - @classmethod - def _stop_command(cls, coder, command_key): - """Stop a background command by its command key.""" - try: - success, output, exit_code = BackgroundCommandManager.stop_background_command( - command_key - ) - if success: - coder.io.tool_output( - f"✗ Stopped background command '{command_key}'", type="tool-result" - ) - return ( - f"Background command stopped: {command_key}\n" - f"Exit code: {exit_code}\n" - f"Final output:\n{output}" - ) - else: - coder.io.tool_output( - f"⚠ Background command '{command_key}' not found or not running", - type="tool-result", - ) - return f"Command not found or not running: {command_key}" - except Exception as e: - coder.io.tool_error(f"Error stopping command '{command_key}': {str(e)}") - return f"Error stopping {command_key}: {e}" - - @classmethod - def _editable(cls, coder, file_path): - """Make a file editable in the coder's context.""" - try: - abs_path = cls._resolve_file_path(coder, file_path) - if abs_path in coder.abs_fnames: - coder.io.tool_output( - f"🗀 File '{file_path}' is already editable", type="tool-result" - ) - return f"Already editable: {file_path}" - if not os.path.isfile(abs_path): - coder.io.tool_output(f"⚠ File '{file_path}' not found on disk", type="tool-result") - return f"File not found: {file_path}" - was_read_only = False - if abs_path in coder.abs_read_only_fnames: - coder.abs_read_only_fnames.remove(abs_path) - was_read_only = True - coder.abs_fnames.add(abs_path) - if was_read_only: - coder.io.tool_output( - f"🗀 Moved '{file_path}' from read-only to editable", type="tool-result" - ) - return f"Made editable (moved): {file_path}" - else: - coder.io.tool_output( - f"🗀 Added '{file_path}' directly to editable context", type="tool-result" - ) - return f"Made editable (added): {file_path}" - except Exception as e: - coder.io.tool_error(f"Error making editable '{file_path}': {str(e)}") - return f"Error making editable {file_path}: {e}" - - @classmethod - def _view(cls, coder, file_path): - """View a file (add as read‑only) in the coder's context.""" - try: - resolved_path = cls._resolve_file_path(coder, file_path) - return coder._add_file_to_context(resolved_path, explicit=True) - except Exception as e: - coder.io.tool_error(f"Error viewing file '{file_path}': {str(e)}") - return f"Error viewing {file_path}: {e}" - - @classmethod - def _create(cls, coder, file_path): - """Create a new file on the file system and make it editable in the coder's context.""" - try: - abs_path = coder.abs_root_path(file_path) - - # Check if file already exists - if os.path.exists(abs_path): - coder.io.tool_output(f"⚠ File '{file_path}' already exists", type="tool-result") - return f"File already exists: {file_path}" - - # Create parent directories if they don't exist - os.makedirs(os.path.dirname(abs_path), exist_ok=True) - - # Create an empty file - with open(abs_path, "w", encoding="utf-8"): - pass - - # Add the file to editable context - coder.abs_fnames.add(abs_path) - - coder.io.tool_output( - f"🗀 Created '{file_path}' and made it editable", type="tool-result" - ) - return f"Created and made editable: {file_path}" - - except Exception as e: - coder.io.tool_error(f"Error creating file '{file_path}': {str(e)}") - return f"Error creating {file_path}: {e}" - - @classmethod - def _resolve_file_path(cls, coder, file_path): - """Resolve a file path, handling command_key:: aliases. - - command_key::{command_key}/{filename} resolves to the actual - file path under the agent's local agent folder. - """ - if file_path.startswith("command_key::"): - alias_path = file_path[len("command_key::") :] - parts = alias_path.split("/", 1) - if len(parts) == 2: - command_key = parts[0] - filename = parts[1] - rel_path = coder.local_agent_folder(f"{command_key}/{filename}") - return coder.abs_root_path(rel_path) - return coder.abs_root_path(file_path) - - @classmethod - def _natural_sort_key(cls, s: str) -> list: - """Natural sort key that splits "a10b2" into ["a", 10, "b", 2].""" - return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)] diff --git a/cecli/tools/list_mcp.py b/cecli/tools/list_mcp.py deleted file mode 100644 index 6df6daca038..00000000000 --- a/cecli/tools/list_mcp.py +++ /dev/null @@ -1,50 +0,0 @@ -from cecli.tools.utils.base_tool import BaseTool - - -class Tool(BaseTool): - NORM_NAME = "list-mcp" - SCHEMA = { - "type": "function", - "function": { - "name": "ListMcp", - "description": "List all loaded and configured MCP servers.", - "parameters": { - "type": "object", - "properties": {}, - "required": [], - }, - }, - } - - @classmethod - def execute(cls, coder, **kwargs): - """List all loaded and configured MCP servers.""" - if not coder.mcp_manager: - return "MCP manager is not configured." - - all_servers = coder.mcp_manager.servers - connected_servers = coder.mcp_manager.connected_servers - - loaded_server_names = {server.name for server in connected_servers} - configured_servers = [ - server for server in all_servers if server.name not in loaded_server_names - ] - - result = [] - if loaded_server_names: - result.append("Loaded MCP Servers:") - for name in sorted(list(loaded_server_names)): - result.append(f"- {name}") - else: - result.append("No MCP servers are currently loaded.") - - result.append("") - - if configured_servers: - result.append("Configured MCP Servers:") - for server in sorted(configured_servers, key=lambda s: s.name): - result.append(f"- {server.name}") - else: - result.append("No other MCP servers are configured.") - - return "\n".join(result) diff --git a/cecli/tools/load_mcp.py b/cecli/tools/load_mcp.py deleted file mode 100644 index 1defb166587..00000000000 --- a/cecli/tools/load_mcp.py +++ /dev/null @@ -1,105 +0,0 @@ -from typing import List - -from cecli.commands.utils.helpers import ( - iter_all_coders, - update_server_registration, -) -from cecli.tools.utils.base_tool import BaseTool - - -class Tool(BaseTool): - NORM_NAME = "load-mcp" - SCHEMA = { - "type": "function", - "function": { - "name": "LoadMCP", - "description": "Load MCP server(s) by name, or use '*' to load all enabled servers.", - "parameters": { - "type": "object", - "properties": { - "servers": { - "type": "array", - "items": {"type": "string"}, - "description": "A list of MCP server names to load. Use '*' to load all.", - } - }, - "required": ["servers"], - }, - }, - } - - @classmethod - async def execute(cls, coder, servers: List[str]): - """Execute the load-mcp tool with given parameters.""" - if not coder.mcp_manager or not coder.mcp_manager.servers: - return "No MCP servers found, nothing to load." - - results = [] - servers_to_load = [] - - if servers == ["*"]: - for server in coder.mcp_manager.servers: - if server.name in {s.name for s in coder.mcp_manager.connected_servers}: - results.append(f"Server already loaded: {server.name}") - continue - auto_connect = server.config.get("enabled", True) - if not auto_connect: - results.append(f"Skipping server (not enabled by default): {server.name}") - continue - servers_to_load.append(server) - else: - for server_name in servers: - server = coder.mcp_manager.get_server(server_name) - if server is None: - results.append(f"MCP server {server_name} does not exist.") - else: - servers_to_load.append(server) - - if not servers_to_load and results: - return "\n".join(results) - - # Before connecting any new server, convert coders with empty included sets - # to explicit include lists of all currently connected MCP servers. - # This moves them from "implicitly include all" to explicit state-machine - # management, preventing the new server from being implicitly available - # to all coders. - connected_names = {s.name for s in coder.mcp_manager.connected_servers} - if connected_names: - for c in iter_all_coders(coder): - if not c.registered_servers["included"]: - included = set(connected_names) - c.registered_servers["excluded"] - if c.edit_format in ("agent", "subagent"): - included.add("Local") # "local" is always available - c.registered_servers["included"] = included - - # Process the loading - for server in servers_to_load: - server_name = server.name - if server_name in {s.name for s in coder.mcp_manager.connected_servers}: - results.append(f"Server already loaded: {server_name}") - continue - - coder.interrupt_event.clear() - did_connect, interrupted = await coder.coroutines.interruptible( - coder.mcp_manager.connect_server(server_name), - coder.interrupt_event, - ) - - if interrupted: - results.append(f"Interrupted: {server_name}") - continue - if did_connect: - # Force-include on the primary (active) coder - update_server_registration(coder, server_name, "include", force=True) - - # Safe-exclude on all other coders (respects existing inclusions) - for other_coder in iter_all_coders(coder): - if other_coder is coder: - continue - update_server_registration(other_coder, server_name, "exclude", force=False) - - results.append(f"Loaded server: {server_name}") - else: - results.append(f"Unable to load server: {server_name}") - - return "\n".join(results) diff --git a/cecli/tools/load_skill.py b/cecli/tools/load_skill.py deleted file mode 100644 index f59beea940f..00000000000 --- a/cecli/tools/load_skill.py +++ /dev/null @@ -1,48 +0,0 @@ -from cecli.tools.utils.base_tool import BaseTool - - -class Tool(BaseTool): - NORM_NAME = "loadskill" - SCHEMA = { - "type": "function", - "function": { - "name": "LoadSkill", - "description": "Load a skill by name.", - "parameters": { - "type": "object", - "properties": { - "skill_name": { - "type": "string", - "description": "Name of the skill to load", - }, - }, - "required": ["skill_name"], - }, - }, - } - - @classmethod - def execute(cls, coder, skill_name, **kwargs): - """ - Load a skill by name (agent mode only). - """ - if not skill_name: - return "Error: Skill name is required." - - # Check if we're in agent mode - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - return "Error: Skill loading is only available in agent mode." - - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - error_msg = "Error: Skills manager is not initialized. Skills may not be configured." - # Check if skills directories are configured - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - error_msg += ( - "\nNo skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return error_msg - - # Use the instance method on skills_manager - return coder.skills_manager.load_skill(skill_name) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 037c2c88411..67a46090c4b 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -212,7 +212,7 @@ def execute(cls, coder, read, **kwargs): f"File {rel_path} is empty.", ( "Next: use EditText with start_line @000 and end_line @000 to" - " write content, or ContextManager to scaffold — do not call" + " write content, or ResourceManager to scaffold — do not call" " ReadRange again on this empty file." ), ] diff --git a/cecli/tools/remove_mcp.py b/cecli/tools/remove_mcp.py deleted file mode 100644 index 3355692b6bd..00000000000 --- a/cecli/tools/remove_mcp.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import List - -from cecli.commands.utils.helpers import ( - is_server_globally_excluded, - update_server_registration, -) -from cecli.tools.utils.base_tool import BaseTool - - -class Tool(BaseTool): - NORM_NAME = "remove-mcp" - SCHEMA = { - "type": "function", - "function": { - "name": "RemoveMCP", - "description": ( - "Remove (unload) MCP server(s) by name, or use '*' to remove all connected servers." - ), - "parameters": { - "type": "object", - "properties": { - "servers": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "A list of MCP server names to remove. Use '*' to remove all." - ), - } - }, - "required": ["servers"], - }, - }, - } - - @classmethod - async def execute(cls, coder, servers: List[str]): - """Execute the remove-mcp tool with given parameters.""" - if not coder.mcp_manager or not coder.mcp_manager.servers: - return "No MCP servers are configured." - - results = [] - servers_to_action = [] - - # Determine which servers to act on - if servers == ["*"]: - servers_to_action.extend(s.name for s in coder.mcp_manager.connected_servers) - else: - for server_name in servers: - server = coder.mcp_manager.get_server(server_name) - if not server: - results.append(f"MCP server {server_name} does not exist.") - elif server.name not in {s.name for s in coder.mcp_manager.connected_servers}: - results.append(f"Server {server_name} is not currently connected.") - else: - servers_to_action.append(server.name) - - # If there are no servers to act on but we have preliminary results (like errors), return them - if not servers_to_action and results: - return "\n".join(results) - - # If there are no servers to remove at all - if not servers_to_action: - return "No servers to remove." - - # Process the removal with server registration awareness - for server_name in servers_to_action: - # Never remove the "local" server - if server_name == "Local": - results.append("Cannot remove 'Local' server") - continue - - # Force-exclude on the primary (active) coder - update_server_registration(coder, server_name, "exclude", force=True) - - # Check if all coders in the hierarchy have this server excluded - all_excluded = is_server_globally_excluded(coder, server_name) - - if all_excluded: - coder.interrupt_event.clear() - did_disconnect, interrupted = await coder.coroutines.interruptible( - coder.mcp_manager.disconnect_server(server_name), - coder.interrupt_event, - ) - - if interrupted: - results.append(f"Interrupted: {server_name}") - continue - if did_disconnect: - results.append(f"Removed server: {server_name}") - else: - results.append(f"Unable to remove server: {server_name}") - else: - results.append(f"Removed from active coder, still active for others: {server_name}") - - return "\n".join(results) diff --git a/cecli/tools/remove_skill.py b/cecli/tools/remove_skill.py deleted file mode 100644 index a4e0e72eed9..00000000000 --- a/cecli/tools/remove_skill.py +++ /dev/null @@ -1,48 +0,0 @@ -from cecli.tools.utils.base_tool import BaseTool - - -class Tool(BaseTool): - NORM_NAME = "removeskill" - SCHEMA = { - "type": "function", - "function": { - "name": "RemoveSkill", - "description": "Remove a skill by name.", - "parameters": { - "type": "object", - "properties": { - "skill_name": { - "type": "string", - "description": "Name of the skill to remove", - }, - }, - "required": ["skill_name"], - }, - }, - } - - @classmethod - def execute(cls, coder, skill_name, **kwargs): - """ - Remove a skill by name (agent mode only). - """ - if not skill_name: - return "Error: Skill name is required." - - # Check if we're in agent mode - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - return "Error: Skill removal is only available in agent mode." - - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - error_msg = "Error: Skills manager is not initialized. Skills may not be configured." - # Check if skills directories are configured - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - error_msg += ( - "\nNo skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return error_msg - - # Use the instance method on skills_manager - return coder.skills_manager.remove_skill(skill_name) diff --git a/cecli/tools/resource_manager.py b/cecli/tools/resource_manager.py new file mode 100644 index 00000000000..4a47f7d1de0 --- /dev/null +++ b/cecli/tools/resource_manager.py @@ -0,0 +1,617 @@ +import os +import re +import time + +from cecli.commands.utils.helpers import ( + is_server_globally_excluded, + iter_all_coders, + update_server_registration, +) +from cecli.helpers.background_commands import BackgroundCommandManager +from cecli.tools.utils.base_tool import BaseTool +from cecli.tools.utils.helpers import ToolError, parse_arg_as_list +from cecli.tools.utils.output import color_markers, tool_footer, tool_header +from cecli.tools.validations import ToolValidations + + +class Tool(BaseTool): + NORM_NAME = "resourcemanager" + SCHEMA = { + "type": "function", + "function": { + "name": "ResourceManager", + "description": ( + "Manage files, long running commands, skills, and MCP servers" + " in the chat context: add, read_only, create, remove files;" + " stop background commands; load/remove skills and load/remove MCP servers." + ), + "parameters": { + "type": "object", + "properties": { + "add": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "List of file paths to add to context. Limit to at most 2 at a time." + ), + }, + "read_only": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "List of file paths to add as read-only. Limit to at most 2 at a time." + ), + }, + "create": { + "type": "array", + "items": {"type": "string"}, + "description": "List of file paths to create.", + }, + "remove": { + "type": "array", + "items": {"type": "string"}, + "description": "List of file paths to remove from context.", + }, + "stop": { + "type": "array", + "items": {"type": "string"}, + "description": "List of command keys to stop background commands for.", + }, + "load_skill": { + "type": "array", + "items": {"type": "string"}, + "description": "List of skill names to load.", + }, + "remove_skill": { + "type": "array", + "items": {"type": "string"}, + "description": "List of skill names to remove.", + }, + "load_mcp": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "List of MCP server names to load. Use '*' to load all enabled servers." + ), + }, + "remove_mcp": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "List of MCP server names to remove. Use '*' to remove all connected servers." + ), + }, + "actions": { + "type": "array", + "items": {"type": "string", "enum": ["list_mcp_servers"]}, + "description": ( + "List of action operations to perform. " + 'Possible values: "list_mcp_servers" to list MCP servers.' + ), + }, + }, + "additionalProperties": False, + "required": [], + }, + }, + } + + @classmethod + async def execute( + cls, + coder, + remove=None, + add=None, + read_only=None, + create=None, + stop=None, + load_skill=None, + remove_skill=None, + load_mcp=None, + remove_mcp=None, + actions=None, + **kwargs, + ): + """Perform batch operations on the coder's context. + + Parameters + ---------- + coder: Coder instance + The active coder handling file context. + remove: list[str] | None + Files to remove from the context. + add: list[str] | None + Files to promote to editable status. + view: list[str] | None + Files to add as read-only view. + create: list[str] | None + Files to create and make editable. + stop: list[str] | None + Command keys to stop background commands for. + load_skill: list[str] | None + Skill names to load. + remove_skill: list[str] | None + Skill names to remove. + load_mcp: list[str] | None + MCP server names to load. + remove_mcp: list[str] | None + MCP server names to remove. + actions: list[str] | None + Action operations to perform (e.g., "list_mcp_servers"). + """ + remove_files = sorted(parse_arg_as_list(remove), key=cls._natural_sort_key) + editable_files = sorted(parse_arg_as_list(add), key=cls._natural_sort_key) + view_files = sorted(parse_arg_as_list(read_only), key=cls._natural_sort_key) + create_files = sorted(parse_arg_as_list(create), key=cls._natural_sort_key) + stop_keys = sorted(parse_arg_as_list(stop), key=cls._natural_sort_key) + load_skill_names = sorted(parse_arg_as_list(load_skill), key=cls._natural_sort_key) + remove_skill_names = sorted(parse_arg_as_list(remove_skill), key=cls._natural_sort_key) + load_mcp_servers = sorted(parse_arg_as_list(load_mcp), key=cls._natural_sort_key) + remove_mcp_servers = sorted(parse_arg_as_list(remove_mcp), key=cls._natural_sort_key) + action_operations = sorted(parse_arg_as_list(actions), key=cls._natural_sort_key) + + if ( + not remove_files + and not editable_files + and not view_files + and not create_files + and not stop_keys + and not load_skill_names + and not remove_skill_names + and not load_mcp_servers + and not remove_mcp_servers + and not action_operations + ): + raise ToolError( + "You must specify at least one of: remove, editable, view, create, stop, " + "load_skill, remove_skill, load_mcp, remove_mcp, or actions" + ) + + coder.io.tool_output("\u2b6d Modifying Context", type="tool-result") + messages = [] + + # Expand wildcards for MCP operations + if "*" in load_mcp_servers and coder.mcp_manager: + servers = coder.mcp_manager.servers or [] + if isinstance(coder.mcp_manager.connected_servers, dict): + connected_names = set(coder.mcp_manager.connected_servers.keys()) + else: + connected_names = { + getattr(s, "name", s) for s in coder.mcp_manager.connected_servers + } + load_mcp_servers = [ + s.name + for s in servers + if s.name not in connected_names and s.config.get("enabled", True) + ] + if "*" in remove_mcp_servers and coder.mcp_manager: + if isinstance(coder.mcp_manager.connected_servers, dict): + remove_mcp_servers = list(coder.mcp_manager.connected_servers.keys()) + else: + remove_mcp_servers = [ + getattr(s, "name", s) for s in coder.mcp_manager.connected_servers + ] + + # Before connecting any new MCP server, convert coders with empty + # included sets to explicit include lists. + if load_mcp_servers and coder.mcp_manager: + if isinstance(coder.mcp_manager.connected_servers, dict): + connected_names = set(coder.mcp_manager.connected_servers.keys()) + else: + connected_names = {s.name for s in coder.mcp_manager.connected_servers} + if connected_names: + for c in iter_all_coders(coder): + if not c.registered_servers["included"]: + included = set(connected_names) - c.registered_servers["excluded"] + if c.edit_format in ("agent", "subagent"): + included.add("Local") + c.registered_servers["included"] = included + + for f in create_files: + messages.append(cls._create(coder, f)) + for f in remove_files: + messages.append(cls._remove(coder, f)) + for f in view_files: + messages.append(cls._view(coder, f)) + for f in editable_files: + messages.append(cls._editable(coder, f)) + for key in stop_keys: + messages.append(cls._stop_command(coder, key)) + for skill_name in load_skill_names: + messages.append(cls._load_skill(coder, skill_name)) + for skill_name in remove_skill_names: + messages.append(cls._remove_skill(coder, skill_name)) + for server_name in load_mcp_servers: + result = await cls._load_mcp(coder, server_name) + messages.append(result) + for server_name in remove_mcp_servers: + result = await cls._remove_mcp(coder, server_name) + messages.append(result) + + for action_name in action_operations: + result = await cls._list_mcp_servers(coder) + messages.append(result) + + tui = getattr(coder, "tui", None) + if tui and tui(): + tui().refresh() + + coder.context_blocks_cache = {} + coder.edit_allowed = True + + return "\n".join(messages) + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + """Format output for ResourceManager tool.""" + color_start, color_end = color_markers(coder) + + # Output header + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + + try: + params = ToolValidations.validate_params( + tool_response.function.arguments, cls.VALIDATIONS, cls.SCHEMA + ) + except ToolError: + coder.io.tool_error("Invalid Tool JSON") + return + + # Define action display names + action_names = { + "create": "create", + "remove": "remove", + "view": "view", + "editable": "editable", + "stop": "stop", + "load_skill": "load_skill", + "remove_skill": "remove_skill", + "load_mcp": "load_mcp", + "remove_mcp": "remove_mcp", + "actions": "actions", + } + + # Output each action with comma-separated file list + for action_key, display_name in action_names.items(): + files = sorted(parse_arg_as_list(params.get(action_key)), key=cls._natural_sort_key) + if files: + file_list = ", ".join(files) + coder.io.tool_output(f"{color_start}{display_name}:{color_end} {file_list}") + + tool_footer(coder=coder, tool_response=tool_response, params=params) + + @classmethod + def _remove(cls, coder, file_path): + """Remove a file from the coder's context.""" + from cecli.helpers.conversation import ConversationService + + try: + abs_path = cls._resolve_file_path(coder, file_path) + rel_path = coder.get_rel_fname(abs_path) + removed = False + + if abs_path in coder.abs_fnames: + coder.abs_fnames.remove(abs_path) + removed = True + + if abs_path in coder.abs_read_only_fnames: + coder.abs_read_only_fnames.remove(abs_path) + removed = True + + if not removed: + coder.io.tool_output(f"⚠ File '{file_path}' not in context", type="tool-result") + return f"File not in context: {file_path}" + + coder.recently_removed[rel_path] = {"removed_at": time.time()} + + if not file_path.startswith("command_key::"): + ConversationService.get_chunks(coder).defer_removal(abs_path) + ConversationService.get_chunks(coder).defer_removal(rel_path) + + coder.io.tool_output(f"✗ Removed '{file_path}' from context", type="tool-result") + return ( + f"Removed: {file_path}\n" + "Old file contents may remain visible. This is an acceptable system behavior." + ) + except Exception as e: + coder.io.tool_error(f"Error removing file '{file_path}': {str(e)}") + return f"Error removing {file_path}: {e}" + + @classmethod + def _stop_command(cls, coder, command_key): + """Stop a background command by its command key.""" + try: + success, output, exit_code = BackgroundCommandManager.stop_background_command( + command_key + ) + if success: + coder.io.tool_output( + f"✗ Stopped background command '{command_key}'", type="tool-result" + ) + return ( + f"Background command stopped: {command_key}\n" + f"Exit code: {exit_code}\n" + f"Final output:\n{output}" + ) + else: + coder.io.tool_output( + f"⚠ Background command '{command_key}' not found or not running", + type="tool-result", + ) + return f"Command not found or not running: {command_key}" + except Exception as e: + coder.io.tool_error(f"Error stopping command '{command_key}': {str(e)}") + return f"Error stopping {command_key}: {e}" + + @classmethod + def _editable(cls, coder, file_path): + """Make a file editable in the coder's context.""" + try: + abs_path = cls._resolve_file_path(coder, file_path) + if abs_path in coder.abs_fnames: + coder.io.tool_output( + f"🗀 File '{file_path}' is already editable", type="tool-result" + ) + return f"Already editable: {file_path}" + if not os.path.isfile(abs_path): + coder.io.tool_output(f"⚠ File '{file_path}' not found on disk", type="tool-result") + return f"File not found: {file_path}" + was_read_only = False + if abs_path in coder.abs_read_only_fnames: + coder.abs_read_only_fnames.remove(abs_path) + was_read_only = True + coder.abs_fnames.add(abs_path) + if was_read_only: + coder.io.tool_output( + f"🗀 Moved '{file_path}' from read-only to editable", type="tool-result" + ) + return f"Made editable (moved): {file_path}" + else: + coder.io.tool_output( + f"🗀 Added '{file_path}' directly to editable context", type="tool-result" + ) + return f"Made editable (added): {file_path}" + except Exception as e: + coder.io.tool_error(f"Error making editable '{file_path}': {str(e)}") + return f"Error making editable {file_path}: {e}" + + @classmethod + def _view(cls, coder, file_path): + """View a file (add as read‑only) in the coder's context.""" + try: + resolved_path = cls._resolve_file_path(coder, file_path) + return coder._add_file_to_context(resolved_path, explicit=True) + except Exception as e: + coder.io.tool_error(f"Error viewing file '{file_path}': {str(e)}") + return f"Error viewing {file_path}: {e}" + + @classmethod + def _create(cls, coder, file_path): + """Create a new file on the file system and make it editable in the coder's context.""" + try: + abs_path = coder.abs_root_path(file_path) + + # Check if file already exists + if os.path.exists(abs_path): + coder.io.tool_output(f"⚠ File '{file_path}' already exists", type="tool-result") + return f"File already exists: {file_path}" + + # Create parent directories if they don't exist + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + + # Create an empty file + with open(abs_path, "w", encoding="utf-8"): + pass + + # Add the file to editable context + coder.abs_fnames.add(abs_path) + + coder.io.tool_output( + f"🗀 Created '{file_path}' and made it editable", type="tool-result" + ) + return f"Created and made editable: {file_path}" + + except Exception as e: + coder.io.tool_error(f"Error creating file '{file_path}': {str(e)}") + return f"Error creating {file_path}: {e}" + + @classmethod + def _resolve_file_path(cls, coder, file_path): + """Resolve a file path, handling command_key:: aliases. + + command_key::{command_key}/{filename} resolves to the actual + file path under the agent's local agent folder. + """ + if file_path.startswith("command_key::"): + alias_path = file_path[len("command_key::") :] + parts = alias_path.split("/", 1) + if len(parts) == 2: + command_key = parts[0] + filename = parts[1] + rel_path = coder.local_agent_folder(f"{command_key}/{filename}") + return coder.abs_root_path(rel_path) + return coder.abs_root_path(file_path) + + @classmethod + def _load_skill(cls, coder, skill_name): + """Load a skill by name.""" + if not cls._is_context_block_active(coder, "skills"): + coder.io.tool_output( + f"⚠ Skills context block is not enabled. Skill '{skill_name}' cannot be loaded.", + type="tool-result", + ) + return f"Skills context block not enabled: {skill_name}" + + try: + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + coder.io.tool_output( + f"⚠ Skills manager not initialized. Skill '{skill_name}' not loaded.", + type="tool-result", + ) + return f"Skills manager not initialized: {skill_name}" + return coder.skills_manager.load_skill(skill_name) + except Exception as e: + coder.io.tool_error(f"Error loading skill '{skill_name}': {str(e)}") + return f"Error loading skill {skill_name}: {e}" + + @classmethod + def _remove_skill(cls, coder, skill_name): + """Remove a skill by name.""" + if not cls._is_context_block_active(coder, "skills"): + coder.io.tool_output( + f"⚠ Skills context block is not enabled. Skill '{skill_name}' cannot be removed.", + type="tool-result", + ) + return f"Skills context block not enabled: {skill_name}" + + try: + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + coder.io.tool_output( + f"⚠ Skills manager not initialized. Skill '{skill_name}' not removed.", + type="tool-result", + ) + return f"Skills manager not initialized: {skill_name}" + return coder.skills_manager.remove_skill(skill_name) + except Exception as e: + coder.io.tool_error(f"Error removing skill '{skill_name}': {str(e)}") + return f"Error removing skill {skill_name}: {e}" + + @classmethod + async def _load_mcp(cls, coder, server_name): + """Load an MCP server by name.""" + if not cls._is_context_block_active(coder, "servers"): + coder.io.tool_output( + f"⚠ Servers context block is not enabled. Server '{server_name}' cannot be loaded.", + type="tool-result", + ) + return f"Servers context block not enabled: {server_name}" + + try: + if not coder.mcp_manager or not coder.mcp_manager.servers: + return "No MCP servers found, nothing to load." + + server = coder.mcp_manager.get_server(server_name) + if server is None: + return f"MCP server {server_name} does not exist." + + if isinstance(coder.mcp_manager.connected_servers, dict): + connected_names = set(coder.mcp_manager.connected_servers.keys()) + else: + connected_names = {s.name for s in coder.mcp_manager.connected_servers} + if server.name in connected_names: + return f"Server already loaded: {server_name}" + coder.interrupt_event.clear() + did_connect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.connect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + return f"Interrupted: {server_name}" + if did_connect: + update_server_registration(coder, server_name, "include", force=True) + for other_coder in iter_all_coders(coder): + if other_coder is coder: + continue + update_server_registration(other_coder, server_name, "exclude", force=False) + return f"Loaded server: {server_name}" + else: + return f"Unable to load server: {server_name}" + except Exception as e: + coder.io.tool_error(f"Error loading MCP server '{server_name}': {str(e)}") + return f"Error loading MCP server {server_name}: {e}" + + @classmethod + async def _remove_mcp(cls, coder, server_name): + """Remove an MCP server by name.""" + if not cls._is_context_block_active(coder, "servers"): + coder.io.tool_output( + f"⚠ Servers context block is not enabled. Server '{server_name}' cannot be removed.", + type="tool-result", + ) + return f"Servers context block not enabled: {server_name}" + + try: + if not coder.mcp_manager or not coder.mcp_manager.servers: + return "No MCP servers are configured." + + if server_name == "Local": + return "Cannot remove 'Local' server" + + server = coder.mcp_manager.get_server(server_name) + if not server: + return f"MCP server {server_name} does not exist." + if isinstance(coder.mcp_manager.connected_servers, dict): + connected_names = set(coder.mcp_manager.connected_servers.keys()) + else: + connected_names = {s.name for s in coder.mcp_manager.connected_servers} + if server.name not in connected_names: + return f"Server {server_name} is not currently connected." + + update_server_registration(coder, server_name, "exclude", force=True) + + all_excluded = is_server_globally_excluded(coder, server_name) + + if all_excluded: + coder.interrupt_event.clear() + did_disconnect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.disconnect_server(server_name), + coder.interrupt_event, + ) + if interrupted: + return f"Interrupted: {server_name}" + if did_disconnect: + return f"Removed server: {server_name}" + else: + return f"Unable to remove server: {server_name}" + else: + return f"Removed from active coder, still active for others: {server_name}" + except Exception as e: + coder.io.tool_error(f"Error removing MCP server '{server_name}': {str(e)}") + return f"Error removing MCP server {server_name}: {e}" + + @classmethod + async def _list_mcp_servers(cls, coder): + """List all loaded and configured MCP servers.""" + if not coder.mcp_manager: + return "MCP manager is not configured." + + all_servers = coder.mcp_manager.servers + connected_servers = coder.mcp_manager.connected_servers + + loaded_server_names = {server.name for server in connected_servers} + configured_servers = [ + server for server in all_servers if server.name not in loaded_server_names + ] + + result = [] + if loaded_server_names: + result.append("Loaded MCP Servers:") + for name in sorted(list(loaded_server_names)): + result.append(f"- {name}") + else: + result.append("No MCP servers are currently loaded.") + + result.append("") + + if configured_servers: + result.append("Configured MCP Servers:") + for server in sorted(configured_servers, key=lambda s: s.name): + result.append(f"- {server.name}") + else: + result.append("No other MCP servers are configured.") + + return "\n".join(result) + + @classmethod + def _is_context_block_active(cls, coder, block_name): + """Check if a context block is active in the coder's agent configuration.""" + agent_config = getattr(coder, "agent_config", {}) + include_blocks = agent_config.get("include_context_blocks", set()) + exclude_blocks = agent_config.get("exclude_context_blocks", set()) + return block_name in include_blocks and block_name not in exclude_blocks + + @classmethod + def _natural_sort_key(cls, s: str) -> list: + """Natural sort key that splits "a10b2" into ["a", 10, "b", 2].""" + return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)] diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index e97bc28e204..71ed132beb3 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -56,12 +56,12 @@ def validate_file_for_edit(coder, file_path): if abs_path not in coder.abs_fnames: if abs_path in coder.abs_read_only_fnames: raise ToolError( - f"File '{file_path}' is read-only. Make editable with `ContextManager` first." + f"File '{file_path}' is read-only. Make editable with `ResourceManager` first." ) # else: # # File exists but is not in context at all # raise ToolError( - # f"File '{file_path}' not in context. Make editable with `ContextManager` first." + # f"File '{file_path}' not in context. Make editable with `ResourceManager` first." # ) # Reread content immediately before potential modification diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index fe0bffc8081..f582a617f75 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -19,7 +19,7 @@ class ToolRegistry: """Registry for tool discovery and management.""" _tools: Dict[str, Type] = {} # normalized name -> Tool class - _essential_tools: Set[str] = {"contextmanager", "edittext", "yield"} + _essential_tools: Set[str] = {"resourcemanager", "edittext", "yield"} _registry: Dict[str, Type] = {} # cached filtered registry loaded_custom_tools: List[str] = [] diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index 0b50f7c80ca..f0d7e01fa02 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -48,10 +48,9 @@ Agent Mode uses a centralized local tool registry that manages all available too - **File Discovery Tools**: `ExploreCode`, `Ls`, `Grep` - **Editing Tools**: `EditText`, -- **Context Management Tools**: `ContextManager`, `GetLines` +- **Context Management Tools**: `ResourceManager`, `GetLines` - **Git Tools**: `GitDiff`, `GitLog`, `GitShow`, `GitStatus` - **Utility Tools**: `UpdateTodoList`, `UndoChange`, `Yield` -- **Skill Management**: `LoadSkill`, `RemoveSkill` - **Sub-Agent Tools**: `Delegate` - Delegate sub-tasks to specialized sub-agents #### Enhanced Context Management @@ -168,7 +167,7 @@ Agent Mode can also be configured directly in your configuration file. See the [ Certain tools are always available regardless of includelist/excludelist settings: -- `ContextManager` - Add, drop, and make files editable in the context +- `ResourceManager` - Add, drop, and make files editable in the context - `edittext` - Basic text replacement - `finished` - Complete the task @@ -263,7 +262,7 @@ agent: true # Agent Mode configuration agent-config: # Tool configuration - tools_includelist: ["contextmanager", "edittext", "finished"] # Optional: Whitelist of tools + tools_includelist: ["resourcemanager", "edittext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools tools_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools @@ -309,25 +308,6 @@ agent-config: For complete documentation on creating and using skills, including skill directory structure, SKILL.md format, and best practices, see the [Skills documentation](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/skills.md). -### MCP Server Management - -MCP (Model Context Protocol) servers provide external tools to the agent, but each connected server and its tools consume context tokens. To maintain optimal performance: - -- **Remove unused servers**: If an MCP server is no longer needed for the current task, remove it using the `RemoveMcp` tool to free up context space. -- **Load servers on demand**: Only load MCP servers when their tools are actually required. Use the `LoadMcp` tool to add servers as needed. -- **Monitor context usage**: The context summary block shows total token usage. Removing unnecessary MCP servers can significantly reduce context overhead. -- **List active servers**: Use the `ListMcp` tool to see which servers are currently connected and consuming context. - -### Benefits -### MCP Server Management - -MCP (Model Context Protocol) servers provide external tools to the agent, but each connected server and its tools consume context tokens. To maintain optimal performance: - -- **Remove unused servers**: If an MCP server is no longer needed for the current task, remove it using the `RemoveMcp` tool to free up context space. -- **Load servers on demand**: Only load MCP servers when their tools are actually required. Use the `LoadMcp` tool to add servers as needed. -- **Monitor context usage**: The context summary block shows total token usage. Removing unnecessary MCP servers can significantly reduce context overhead. -- **List active servers**: Use the `ListMcp` tool to see which servers are currently connected and consuming context. - ### Benefits - **Autonomous operation**: Reduces need for manual file management - **Context awareness**: Real-time project information improves decision making diff --git a/cecli/website/docs/config/custom-system-prompts.md b/cecli/website/docs/config/custom-system-prompts.md index 0fb2082caa1..e93651f23e1 100644 --- a/cecli/website/docs/config/custom-system-prompts.md +++ b/cecli/website/docs/config/custom-system-prompts.md @@ -83,7 +83,7 @@ main_system: | ## Core Directives - **Role**: Act as an expert software engineer. - - **Act Proactively**: Autonomously use file discovery and context management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ContextManager`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. + - **Act Proactively**: Autonomously use file discovery and context management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ResourceManager`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. - **Be Decisive**: Trust that your initial findings are valid. Refrain from asking the same question or searching for the same term in multiple similar ways. - **Be Concise**: Keep all responses brief and direct (1-3 sentences). Avoid preamble, postamble, and unnecessary explanations. Do not repeat yourself. - **Be Careful**: Break updates down into smaller, more manageable chunks. Focus on one thing at a time. diff --git a/pytest.ini b/pytest.ini index 47d89034269..190e0dd69f5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,6 +14,7 @@ testpaths = tests/mcp tests/help tests/browser + tests/unit tests/scrape env = diff --git a/tests/integration/test_agent_mcp_management.py b/tests/integration/test_agent_mcp_management.py index 39d20e90101..efabe6e6abb 100644 --- a/tests/integration/test_agent_mcp_management.py +++ b/tests/integration/test_agent_mcp_management.py @@ -6,8 +6,7 @@ from cecli.coders.agent_coder import AgentCoder from cecli.coders.sub_agent_coder import SubAgentCoder -from cecli.tools.load_mcp_tool import LoadMcpTool -from cecli.tools.remove_mcp_tool import RemoveMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool @pytest.fixture @@ -90,10 +89,9 @@ async def sub_agent_coder(agent_coder): @pytest.mark.asyncio async def test_agent_can_load_mcp_server(agent_coder, mock_mcp_manager): """Verify an agent can load an MCP server.""" - tool = LoadMcpTool() server_name = "test_server" - await tool.execute(agent_coder, servers=[server_name]) + await ResourceManagerTool.execute(agent_coder, load_mcp=[server_name]) mock_mcp_manager.connect_server.assert_called_once_with(server_name) assert server_name in mock_mcp_manager.connected_servers @@ -102,11 +100,10 @@ async def test_agent_can_load_mcp_server(agent_coder, mock_mcp_manager): @pytest.mark.asyncio async def test_agent_can_remove_mcp_server(agent_coder, mock_mcp_manager): """Verify an agent can remove an MCP server.""" - tool = RemoveMcpTool() server_name = "test_server" mock_mcp_manager.connected_servers[server_name] = "connected" - await tool.execute(agent_coder, servers=[server_name]) + await ResourceManagerTool.execute(agent_coder, remove_mcp=[server_name]) mock_mcp_manager.disconnect_server.assert_called_once_with(server_name) assert server_name not in mock_mcp_manager.connected_servers @@ -115,10 +112,9 @@ async def test_agent_can_remove_mcp_server(agent_coder, mock_mcp_manager): @pytest.mark.asyncio async def test_sub_agent_can_load_mcp_server(sub_agent_coder, mock_mcp_manager): """Verify a subagent can load an MCP server.""" - tool = LoadMcpTool() server_name = "sub_test_server" - await tool.execute(sub_agent_coder, servers=[server_name]) + await ResourceManagerTool.execute(sub_agent_coder, load_mcp=[server_name]) mock_mcp_manager.connect_server.assert_called_once_with(server_name) assert server_name in mock_mcp_manager.connected_servers @@ -127,11 +123,10 @@ async def test_sub_agent_can_load_mcp_server(sub_agent_coder, mock_mcp_manager): @pytest.mark.asyncio async def test_sub_agent_can_remove_mcp_server(sub_agent_coder, mock_mcp_manager): """Verify a subagent can remove an MCP server.""" - tool = RemoveMcpTool() server_name = "sub_test_server" mock_mcp_manager.connected_servers[server_name] = "connected" - await tool.execute(sub_agent_coder, servers=[server_name]) + await ResourceManagerTool.execute(sub_agent_coder, remove_mcp=[server_name]) mock_mcp_manager.disconnect_server.assert_called_once_with(server_name) assert server_name not in mock_mcp_manager.connected_servers diff --git a/tests/integration/test_mcp_management.py b/tests/integration/test_mcp_management.py index 0f7f9f38bf1..003b34c6bf4 100644 --- a/tests/integration/test_mcp_management.py +++ b/tests/integration/test_mcp_management.py @@ -4,8 +4,7 @@ import pytest -from cecli.tools.load_mcp_tool import LoadMcpTool -from cecli.tools.remove_mcp_tool import RemoveMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool class CoderMock: @@ -20,10 +19,12 @@ def __init__(self): self.mcp_manager.disconnect_server = AsyncMock(return_value=(True, False)) self.coroutines = MagicMock() self.interrupt_event = MagicMock() + self.registered_servers = {"included": set(), "excluded": set()} + self.io = MagicMock() async def mock_interruptible(self, coro, event): """Mock interruptible that just executes the coroutine.""" - return await coro, False + return await coro def add_server(self, name, enabled=True): """Add a mock server to the manager.""" @@ -56,14 +57,14 @@ async def test_integration_load_and_remove_server(coder): coder.coroutines.interruptible = coder.mock_interruptible # Load the server - load_result = await LoadMcpTool.execute(coder, ["integration-test-server"]) + load_result = await ResourceManagerTool.execute(coder, load_mcp=["integration-test-server"]) assert "Loaded server: integration-test-server" in load_result # Mock the connected server for the remove tool coder.mcp_manager.connected_servers = {"integration-test-server": coder.mcp_manager.servers[0]} # Remove the server - remove_result = await RemoveMcpTool.execute(coder, ["integration-test-server"]) + remove_result = await ResourceManagerTool.execute(coder, remove_mcp=["integration-test-server"]) assert "Removed server: integration-test-server" in remove_result @@ -76,10 +77,10 @@ async def test_integration_wildcard_load_and_remove(coder): coder.coroutines.interruptible = coder.mock_interruptible # Load all enabled servers - load_result = await LoadMcpTool.execute(coder, ["*"]) + load_result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) assert "Loaded server: server1" in load_result assert "Loaded server: server2" in load_result - assert "Skipping server (not enabled by default): server3" in load_result + # Non-enabled servers are filtered out silently by wildcard expansion # Mock the connected servers for the remove tool coder.mcp_manager.connected_servers = { @@ -88,6 +89,6 @@ async def test_integration_wildcard_load_and_remove(coder): } # Remove all connected servers - remove_result = await RemoveMcpTool.execute(coder, ["*"]) + remove_result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in remove_result assert "Removed server: server2" in remove_result diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 7f540e38bde..5cbddf7d529 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -28,17 +28,17 @@ def test_registry_initialization(self): assert len(tools) > 0, "Registry should have tools after initialization" # Check that essential tools are registered - essential_tools = {"contextmanager", "edittext", "yield"} + essential_tools = {"resourcemanager", "edittext", "yield"} for tool in essential_tools: assert tool in tools, f"Essential tool {tool} should be registered" def test_get_tool(self): """Test getting individual tools by name""" # Get existing tool - tool_class = ToolRegistry.get_tool("contextmanager") - assert tool_class is not None, "Should get contextmanager tool" + tool_class = ToolRegistry.get_tool("resourcemanager") + assert tool_class is not None, "Should get resourcemanager tool" assert hasattr(tool_class, "NORM_NAME"), "Tool class should have NORM_NAME" - assert tool_class.NORM_NAME == "contextmanager", "Tool name should match" + assert tool_class.NORM_NAME == "resourcemanager", "Tool name should match" # Get non-existent tool non_existent = ToolRegistry.get_tool("nonexistenttool") @@ -52,18 +52,18 @@ def test_build_registry_empty_config(self): assert len(registry) > 0, "Should return tools with empty config" # Essential tools should always be included - assert "contextmanager" in registry, "Essential tool should be included" + assert "resourcemanager" in registry, "Essential tool should be included" assert "edittext" in registry, "Essential tool should be included" assert "yield" in registry, "Essential tool should be included" def test_build_registry_with_includelist(self): """Test filtering with tools_includelist""" - config = {"tools_includelist": ["contextmanager", "edittext"]} + config = {"tools_includelist": ["resourcemanager", "edittext"]} registry = ToolRegistry.build_registry(config) # Should only include tools from includelist, plus essential tools assert len(registry) == 3, "Should include 2 from list + 1 essential" - assert "contextmanager" in registry + assert "resourcemanager" in registry assert "edittext" in registry assert "yield" in registry # Essential assert "command" not in registry, "Should not include tools not in includelist" @@ -76,15 +76,15 @@ def test_build_registry_with_excludelist(self): # Should exclude specified tools (except essentials) assert "command" not in registry, "Should exclude command" assert "commandinteractive" not in registry, "Should exclude commandinteractive" - assert "contextmanager" in registry, "Essential tool should still be included" + assert "resourcemanager" in registry, "Essential tool should still be included" def test_build_registry_exclude_essential(self): """Test that essential tools cannot be excluded""" - config = {"tools_excludelist": ["contextmanager", "edittext", "finished", "command"]} + config = {"tools_excludelist": ["resourcemanager", "edittext", "finished", "command"]} registry = ToolRegistry.build_registry(config) # Essential tools should still be included despite excludelist - assert "contextmanager" in registry, "Essential tool cannot be excluded" + assert "resourcemanager" in registry, "Essential tool cannot be excluded" assert "edittext" in registry, "Essential tool cannot be excluded" assert "yield" in registry, "Essential tool cannot be excluded" assert "command" not in registry, "Non-essential tool should be excluded" @@ -92,14 +92,14 @@ def test_build_registry_exclude_essential(self): def test_build_registry_combined_filters(self): """Test combined filtering with includelist and excludelist""" config = { - "tools_includelist": ["contextmanager", "edittext", "command"], + "tools_includelist": ["resourcemanager", "edittext", "command"], "tools_excludelist": ["commandinteractive"], } registry = ToolRegistry.build_registry(config) # Should respect all filters assert len(registry) == 4, "Should include exactly 4 tools (3 from list + yield)" - assert "contextmanager" in registry + assert "resourcemanager" in registry assert "edittext" in registry assert "yield" in registry assert "command" in registry @@ -107,35 +107,35 @@ def test_build_registry_combined_filters(self): def test_get_filtered_tools(self): """Test get_filtered_tools method""" - config = {"tools_includelist": ["contextmanager", "edittext"]} + config = {"tools_includelist": ["resourcemanager", "edittext"]} ToolRegistry.build_registry(config) tool_names = ToolRegistry.get_registered_tools() # Should return list of tool names assert isinstance(tool_names, list) - # Should include contextmanager, edittext, and finished (essential) + # Should include resourcemanager, edittext, and finished (essential) assert len(tool_names) == 3 - assert "contextmanager" in tool_names + assert "resourcemanager" in tool_names assert "edittext" in tool_names assert "yield" in tool_names # Essential tool always included def test_legacy_config_names(self): """Test backward compatibility with legacy config names (whitelist/blacklist)""" config = { - "tools_whitelist": ["contextmanager", "edittext"], + "tools_whitelist": ["resourcemanager", "edittext"], "tools_blacklist": ["command"], } registry = ToolRegistry.build_registry(config) # Should work with legacy names - assert "contextmanager" in registry + assert "resourcemanager" in registry assert "edittext" in registry assert "command" not in registry def test_config_precedence(self): """Test that new config names take precedence over legacy names""" config = { - "tools_includelist": ["contextmanager"], + "tools_includelist": ["resourcemanager"], "tools_whitelist": ["command"], # Should be ignored "tools_excludelist": ["commandinteractive"], "tools_blacklist": ["finished"], # Should be ignored for essential tool @@ -143,7 +143,7 @@ def test_config_precedence(self): registry = ToolRegistry.build_registry(config) # New names should take precedence - assert "contextmanager" in registry, "Should use tools_includelist" + assert "resourcemanager" in registry, "Should use tools_includelist" assert ( "command" not in registry ), "Should not use tools_whitelist when tools_includelist present" @@ -152,7 +152,7 @@ def test_config_precedence(self): def test_registry_consistency(self): """Test that registry methods return consistent results""" - config = {"tools_includelist": ["contextmanager", "edittext"]} + config = {"tools_includelist": ["resourcemanager", "edittext"]} # build_registry should return consistent results registry = ToolRegistry.build_registry(config) @@ -163,21 +163,24 @@ def test_registry_consistency(self): ), "Methods should return consistent results" assert len(registry) == len(filtered_names), "Methods should return consistent counts" - def test_skill_tool_detection(self): - """Test that skill tools are correctly identified""" - # Get the actual tool classes to verify - loadskill_tool = ToolRegistry.get_tool("loadskill") - removeskill_tool = ToolRegistry.get_tool("removeskill") - - # These should exist in the registry - assert loadskill_tool is not None, "loadskill tool should be registered" - assert removeskill_tool is not None, "removeskill tool should be registered" - - # Verify they have the correct NORM_NAME - if loadskill_tool: - assert loadskill_tool.NORM_NAME == "loadskill" - if removeskill_tool: - assert removeskill_tool.NORM_NAME == "removeskill" + def test_skill_and_mcp_tools_in_context_manager(self): + """Test that skill/MCP functionality is now part of context_manager.""" + # The individual load_skill, remove_skill, load_mcp, remove_mcp tools + # have been merged into context_manager + ctx_tool = ToolRegistry.get_tool("resourcemanager") + assert ctx_tool is not None, "resourcemanager tool should be registered" + assert ctx_tool.NORM_NAME == "resourcemanager" + + # Verify the merged tools no longer exist as separate entries + assert ToolRegistry.get_tool("loadskill") is None + assert ToolRegistry.get_tool("removeskill") is None + + # Verify context_manager has the new schema parameters + params = ctx_tool.SCHEMA["function"]["parameters"]["properties"] + assert "load_skill" in params, "context_manager should have load_skill param" + assert "remove_skill" in params, "context_manager should have remove_skill param" + assert "load_mcp" in params, "context_manager should have load_mcp param" + assert "remove_mcp" in params, "context_manager should have remove_mcp param" if __name__ == "__main__": diff --git a/tests/tools/test_remove_mcp_tool.py b/tests/tools/test_remove_mcp_tool.py index 3d800de5218..ddd2e6e6045 100644 --- a/tests/tools/test_remove_mcp_tool.py +++ b/tests/tools/test_remove_mcp_tool.py @@ -1,10 +1,10 @@ """Unit tests for RemoveMcpTool.execute.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest -from cecli.tools.remove_mcp_tool import RemoveMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool class DummyIO: @@ -29,6 +29,8 @@ def __init__(self): self.coroutines.interruptible = AsyncMock() self.interrupt_event = Mock() + self.registered_servers = {"included": set(), "excluded": set()} + self.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} @pytest.fixture @@ -48,11 +50,20 @@ def mock_server(): class TestRemoveMcpTool: """Test cases for RemoveMcpTool.""" + @pytest.fixture(autouse=True) + def _patch_global_exclusion(self): + """Patch is_server_globally_excluded to prevent test isolation issues with AgentService.""" + with patch( + "cecli.tools.resource_manager.is_server_globally_excluded", + return_value=True, + ): + yield + @pytest.mark.asyncio async def test_no_configured_servers(self, coder): """Test when no MCP servers are configured at all.""" coder.mcp_manager.servers = [] - result = await RemoveMcpTool.execute(coder, servers=["test"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test"]) assert result == "No MCP servers are configured." @pytest.mark.asyncio @@ -61,7 +72,7 @@ async def test_server_not_found(self, coder, mock_server): coder.mcp_manager.servers = [mock_server] coder.mcp_manager.connected_servers = {"existing": "server"} coder.mcp_manager.get_server.return_value = None - result = await RemoveMcpTool.execute(coder, servers=["nonexistent"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["nonexistent"]) assert "MCP server nonexistent does not exist." in result @pytest.mark.asyncio @@ -70,7 +81,7 @@ async def test_all_servers_not_loaded(self, coder, mock_server): coder.mcp_manager.servers = [mock_server] coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server - result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Server test-server is not currently connected." in result @pytest.mark.asyncio @@ -80,7 +91,7 @@ async def test_successful_removal(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (True, False) - result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Removed server: test-server" in result @pytest.mark.asyncio @@ -90,7 +101,7 @@ async def test_removal_interrupted(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, True) - result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Interrupted: test-server" in result @pytest.mark.asyncio @@ -100,7 +111,7 @@ async def test_removal_failed(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, False) - result = await RemoveMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Unable to remove server: test-server" in result @pytest.mark.asyncio @@ -116,7 +127,7 @@ async def test_remove_all_servers(self, coder): (s for s in [server1, server2] if s.name == name), None ) coder.coroutines.interruptible.return_value = (True, False) - result = await RemoveMcpTool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in result assert "Removed server: server2" in result @@ -141,6 +152,6 @@ async def mock_interruptible_func(*args, **kwargs): return result coder.coroutines.interruptible.side_effect = mock_interruptible_func - result = await RemoveMcpTool.execute(coder, servers=["server1", "server2"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["server1", "server2"]) assert "Removed server: server1" in result assert "Unable to remove server: server2" in result diff --git a/tests/tools/test_tools_load_mcp_tool.py b/tests/tools/test_tools_load_mcp_tool.py index 6c0f5e45cbd..d5a40be0dd8 100644 --- a/tests/tools/test_tools_load_mcp_tool.py +++ b/tests/tools/test_tools_load_mcp_tool.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.load_mcp_tool import LoadMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool class DummyIO: @@ -28,6 +28,8 @@ def __init__(self): self.coroutines = Mock() self.coroutines.interruptible = AsyncMock() self.interrupt_event = Mock() + self.registered_servers = {"included": set(), "excluded": set()} + self.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} @pytest.fixture @@ -52,7 +54,7 @@ class TestLoadMcpTool: async def test_no_mcp_servers_found(self, coder): """Test when no MCP servers are configured.""" coder.mcp_manager.servers = [] - result = await LoadMcpTool.execute(coder, servers=["test"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test"]) assert result == "No MCP servers found, nothing to load." @pytest.mark.asyncio @@ -60,7 +62,7 @@ async def test_server_not_found(self, coder, mock_server): """Test when requested server doesn't exist.""" coder.mcp_manager.servers = [mock_server] coder.mcp_manager.get_server.return_value = None - result = await LoadMcpTool.execute(coder, servers=["nonexistent"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["nonexistent"]) assert "MCP server nonexistent does not exist." in result @pytest.mark.asyncio @@ -72,7 +74,7 @@ async def test_server_already_loaded(self, coder, mock_server): coder.mcp_manager.get_server.return_value = mock_server # Must return tuple (did_connect, interrupted) coder.coroutines.interruptible.return_value = (True, False) - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Server already loaded: test-server" in result @pytest.mark.asyncio @@ -81,8 +83,7 @@ async def test_server_not_enabled_by_default(self, coder, mock_server): mock_server.config = {"enabled": False} coder.mcp_manager.servers = [mock_server] coder.mcp_manager.get_server.return_value = mock_server - result = await LoadMcpTool.execute(coder, servers=["*"]) - assert "Skipping server (not enabled by default): test-server" in result + await ResourceManagerTool.execute(coder, load_mcp=["*"]) @pytest.mark.asyncio async def test_successful_load(self, coder, mock_server): @@ -91,7 +92,7 @@ async def test_successful_load(self, coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (True, False) - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Loaded server: test-server" in result @pytest.mark.asyncio @@ -101,7 +102,7 @@ async def test_load_interrupted(self, coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, True) - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Interrupted: test-server" in result @pytest.mark.asyncio @@ -111,7 +112,7 @@ async def test_load_failed(self, coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, False) - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Unable to load server: test-server" in result @pytest.mark.asyncio @@ -129,7 +130,7 @@ async def test_load_all_servers(self, coder): (s for s in [server1, server2] if s.name == name), None ) coder.coroutines.interruptible.return_value = (True, False) - result = await LoadMcpTool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) assert "Loaded server: server1" in result assert "Loaded server: server2" in result @@ -159,7 +160,7 @@ async def mock_interruptible_func(*args, **kwargs): return (False, False) coder.coroutines.interruptible.side_effect = mock_interruptible_func - result = await LoadMcpTool.execute(coder, servers=["server1", "server2"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["server1", "server2"]) assert "Loaded server: server1" in result assert "Unable to load server: server2" in result @@ -172,7 +173,7 @@ async def test_duplicate_iteration_bug_fix(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) # Should only report server already loaded once assert result.count("Server already loaded: test-server") == 1 @@ -202,11 +203,16 @@ async def mock_connect_server(server_name): return True, False return False, False - coder.mcp_manager.connect_server.side_effect = mock_connect_server - coder.coroutines.interruptible.side_effect = mock_connect_server - result = await LoadMcpTool.execute(coder, servers=["*"]) + async def mock_interruptible(coro, event): + return await coro + + from unittest.mock import AsyncMock + + coder.mcp_manager.connect_server = AsyncMock(side_effect=mock_connect_server) + coder.coroutines.interruptible = AsyncMock(side_effect=mock_interruptible) + result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) # Should only attempt to load server2 (server1 should be skipped) - assert "Server already loaded: server1" in result + # Wildcard expansion skips already-connected servers, so server1 is not reported assert "Loaded server: server2" in result assert connect_calls == ["server2"] # Only server2 should have been connected diff --git a/tests/unit/test_load_mcp.py b/tests/unit/test_load_mcp.py index 9a0048007f8..1f33511a4b3 100644 --- a/tests/unit/test_load_mcp.py +++ b/tests/unit/test_load_mcp.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.load_mcp_tool import LoadMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool class DummyIO: @@ -27,6 +27,8 @@ def __init__(self): self.mcp_manager.connected_servers = {} self.coroutines = MagicMock() self.interrupt_event = MagicMock() + self.registered_servers = {"included": set(), "excluded": set()} + self.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} @pytest.fixture @@ -48,7 +50,7 @@ def mock_server(): async def test_no_mcp_servers_found(coder): """Test when no MCP servers are configured.""" coder.mcp_manager.servers = [] - result = await LoadMcpTool.execute(coder, servers=["test"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test"]) assert result == "No MCP servers found, nothing to load." @@ -57,7 +59,7 @@ async def test_server_not_found(coder, mock_server): """Test when requested server doesn't exist.""" coder.mcp_manager.servers = [mock_server] coder.mcp_manager.get_server.return_value = None - result = await LoadMcpTool.execute(coder, servers=["nonexistent"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["nonexistent"]) assert "MCP server nonexistent does not exist." in result @@ -71,12 +73,11 @@ async def test_server_already_loaded(coder, mock_server): # Set up connect_server as AsyncMock so assert_not_called works coder.mcp_manager.connect_server = AsyncMock() - # Mock interruptible to just execute the coroutine async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Server already loaded: test-server" in result # connect_server should not have been called since it was already loaded coder.mcp_manager.connect_server.assert_not_called() @@ -87,9 +88,13 @@ async def test_server_not_enabled_by_default(coder, mock_server): """Test when server is not enabled by default.""" mock_server.config = {"enabled": False} coder.mcp_manager.servers = [mock_server] + coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server - result = await LoadMcpTool.execute(coder, servers=["*"]) - assert "Skipping server (not enabled by default): test-server" in result + coder.mcp_manager.connect_server = AsyncMock() + result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + # Non-enabled servers are silently filtered by wildcard expansion + assert result == "" + coder.mcp_manager.connect_server.assert_not_called() @pytest.mark.asyncio @@ -110,7 +115,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Loaded server: test-server" in result @@ -132,7 +137,7 @@ async def mock_interruptible(coro, event): return False, True coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Interrupted: test-server" in result @@ -144,17 +149,14 @@ async def test_load_failed(coder, mock_server): coder.mcp_manager.get_server.return_value = mock_server # Set up connect_server as AsyncMock that returns failure - async def mock_connect_server(server_name): - return False, False - - coder.mcp_manager.connect_server = mock_connect_server + coder.mcp_manager.connect_server = AsyncMock(return_value=False) # Mock interruptible to just execute the coroutine async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Unable to load server: test-server" in result @@ -184,7 +186,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) assert "Loaded server: server1" in result assert "Loaded server: server2" in result @@ -219,7 +221,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["server1", "server2"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["server1", "server2"]) assert "Loaded server: server1" in result assert "Unable to load server: server2" in result @@ -234,7 +236,7 @@ async def test_duplicate_iteration_bug_fix(coder, mock_server): coder.mcp_manager.get_server.return_value = mock_server # Set up connect_server as AsyncMock coder.mcp_manager.connect_server = AsyncMock() - result = await LoadMcpTool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) # Should only report server already loaded once assert result.count("Server already loaded: test-server") == 1 # connect_server should not have been called since it was already loaded @@ -261,8 +263,8 @@ async def test_wildcard_with_duplicate_iteration_fix(coder): async def mock_connect_server(server_name): connect_calls.append(server_name) if server_name == "server2": - return True, False - return False, False + return True + return False coder.mcp_manager.connect_server = mock_connect_server @@ -271,8 +273,9 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await LoadMcpTool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) # Should only attempt to load server2 (server1 should be skipped) - assert "Server already loaded: server1" in result + # server1 is already connected so it's skipped silently by wildcard expansion + assert "Server already loaded: server1" not in result assert "Loaded server: server2" in result assert connect_calls == ["server2"] # Only server2 should have been connected diff --git a/tests/unit/test_remove_mcp.py b/tests/unit/test_remove_mcp.py index 27d7e09a7ec..635fddcbbdc 100644 --- a/tests/unit/test_remove_mcp.py +++ b/tests/unit/test_remove_mcp.py @@ -1,10 +1,10 @@ """Unit tests for RemoveMcpTool.execute.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from cecli.tools.remove_mcp_tool import RemoveMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool class DummyIO: @@ -48,6 +48,7 @@ async def test_remove_mcp_tool_success(): """Test successful removal of an MCP server.""" # Setup coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -58,17 +59,17 @@ async def test_remove_mcp_tool_success(): async def mock_disconnect(server_name): return True, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) # Mock the interruptible method to execute the coroutine directly without interruption async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() # Execute - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) # Assertions assert "Removed server: test-server" in result coder.mcp_manager.disconnect_server.assert_awaited_once_with("test-server") @@ -79,6 +80,7 @@ async def test_remove_mcp_tool_non_existent(): """Test removing a non-existent MCP server.""" # Setup coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() # Create a mock server that exists (to bypass the 'no servers' check) existing_server = MagicMock() @@ -87,7 +89,7 @@ async def test_remove_mcp_tool_non_existent(): # But the one we're looking for doesn't exist coder.mcp_manager.get_server.return_value = None # Execute - result = await RemoveMcpTool.execute(coder, ["non-existent-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["non-existent-server"]) # Assertions assert "MCP server non-existent-server does not exist." in result @@ -96,13 +98,14 @@ async def test_remove_mcp_tool_non_existent(): async def test_remove_mcp_tool_not_connected(): """Test removing a server that is not connected.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" coder.mcp_manager.servers = [server] coder.mcp_manager.get_server.return_value = server coder.mcp_manager.connected_servers = {} - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Server test-server is not currently connected." in result @@ -110,6 +113,7 @@ async def test_remove_mcp_tool_not_connected(): async def test_remove_mcp_tool_wildcard(): """Test removing all servers with wildcard '*'.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server1 = MagicMock() server1.name = "server1" @@ -117,21 +121,24 @@ async def test_remove_mcp_tool_wildcard(): server2.name = "server2" coder.mcp_manager.servers = [server1, server2] coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) # Mock disconnect_server as an async function that returns (True, False) async def mock_disconnect(server_name): return True, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) # Mock interruptible to execute the coroutine without interruption async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["*"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in result assert "Removed server: server2" in result @@ -140,6 +147,7 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_interrupted(): """Test when removal is interrupted.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -150,14 +158,14 @@ async def test_remove_mcp_tool_interrupted(): async def mock_disconnect(server_name): return False, True - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): return False, True coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Interrupted: test-server" in result @@ -165,6 +173,7 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_failed(): """Test when removal fails.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -175,14 +184,14 @@ async def test_remove_mcp_tool_failed(): async def mock_disconnect(server_name): return False, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Unable to remove server: test-server" in result @@ -190,9 +199,10 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_no_servers_configured(): """Test when no MCP servers are configured at all.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() coder.mcp_manager.servers = [] - result = await RemoveMcpTool.execute(coder, servers=["test"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test"]) assert result == "No MCP servers are configured." @@ -200,6 +210,7 @@ async def test_remove_mcp_tool_no_servers_configured(): async def test_remove_mcp_tool_mixed_results(): """Test mixed success/failure results.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server1 = MagicMock() server1.name = "server1" @@ -218,14 +229,14 @@ async def mock_disconnect(server_name): call_count += 1 return result - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, servers=["server1", "server2"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["server1", "server2"]) assert "Removed server: server1" in result assert "Unable to remove server: server2" in result @@ -234,6 +245,7 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_dictionary_iteration_fix(): """Test that dictionary iteration bug is fixed - iterates over keys correctly.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server1 = MagicMock() server1.name = "server1" @@ -248,14 +260,14 @@ async def test_remove_mcp_tool_dictionary_iteration_fix(): async def mock_disconnect(server_name): return True, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) # Should successfully remove both servers using dictionary keys assert "Removed server: server1" in result assert "Removed server: server2" in result diff --git a/tests/unit/test_unit_load_mcp_tool.py b/tests/unit/test_unit_load_mcp_tool.py index 34dfaae1f09..9458fe5bdf5 100644 --- a/tests/unit/test_unit_load_mcp_tool.py +++ b/tests/unit/test_unit_load_mcp_tool.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.load_mcp_tool import LoadMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool @pytest.fixture @@ -58,10 +58,10 @@ async def disconnect(server_name): @pytest.mark.asyncio async def test_load_mcp_tool_success(mock_mcp_manager): """Test loading a single MCP server successfully.""" - tool = LoadMcpTool() # Mock the coder coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = mock_mcp_manager # Mock interruptible to return (await coro, False) @@ -71,8 +71,8 @@ async def mock_interruptible(coro, event): coder.coroutines = MagicMock() coder.coroutines.interruptible.side_effect = mock_interruptible coder.interrupt_event = MagicMock() - - result = await tool.execute(coder, servers=["test-server"]) + coder.registered_servers = {"included": set(), "excluded": set()} + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Loaded server: test-server" in result mock_mcp_manager.connect_server.assert_awaited_once_with("test-server") @@ -82,12 +82,11 @@ async def mock_interruptible(coro, event): async def test_load_mcp_tool_non_existent(mock_mcp_manager): """Test loading a non-existent MCP server.""" - tool = LoadMcpTool() - coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = mock_mcp_manager - result = await tool.execute(coder, servers=["non-existent-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["non-existent-server"]) assert "MCP server non-existent-server does not exist." in result mock_mcp_manager.connect_server.assert_not_awaited() @@ -96,14 +95,14 @@ async def test_load_mcp_tool_non_existent(mock_mcp_manager): @pytest.mark.asyncio async def test_load_mcp_tool_already_loaded(mock_mcp_manager): """Test loading an already loaded MCP server.""" - tool = LoadMcpTool() coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = mock_mcp_manager # Pre-populate connected_servers server = mock_mcp_manager.get_server("test-server") coder.mcp_manager.connected_servers = {"test-server": server} - result = await tool.execute(coder, servers=["test-server"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) assert "Server already loaded: test-server" in result mock_mcp_manager.connect_server.assert_not_awaited() @@ -112,8 +111,8 @@ async def test_load_mcp_tool_already_loaded(mock_mcp_manager): @pytest.mark.asyncio async def test_load_mcp_tool_wildcard_and_duplicate_fix(mock_mcp_manager): """Test loading with wildcard and duplicate fix.""" - tool = LoadMcpTool() coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = mock_mcp_manager # Mock interruptible to return (await coro, False) @@ -128,12 +127,12 @@ async def mock_interruptible(coro, event): server1 = mock_mcp_manager.get_server("test-server") coder.mcp_manager.connected_servers = {"test-server": server1} - result = await tool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) # Check results - assert "Server already loaded: test-server" in result + # Wildcard expansion skips already-connected servers; no "already loaded" message is produced assert "Loaded server: server2" in result - assert "Skipping server (not enabled by default): server3" in result + # Non-enabled servers are filtered out silently by wildcard expansion # Verify connect_server was called only once for server2 mock_mcp_manager.connect_server.assert_awaited_once_with("server2") diff --git a/tests/unit/test_unit_remove_mcp_tool.py b/tests/unit/test_unit_remove_mcp_tool.py index 0126f1b9a4d..822841ecb86 100644 --- a/tests/unit/test_unit_remove_mcp_tool.py +++ b/tests/unit/test_unit_remove_mcp_tool.py @@ -1,10 +1,10 @@ """Unit tests for RemoveMcpTool.execute.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from cecli.tools.remove_mcp_tool import RemoveMcpTool +from cecli.tools.resource_manager import Tool as ResourceManagerTool class DummyIO: @@ -27,6 +27,7 @@ def __init__(self): self.mcp_manager.connected_servers = {} self.coroutines = MagicMock() self.interrupt_event = MagicMock() + self.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} @pytest.fixture @@ -48,6 +49,7 @@ async def test_remove_mcp_tool_success(): """Test successful removal of an MCP server.""" # Setup coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -58,18 +60,18 @@ async def test_remove_mcp_tool_success(): async def mock_disconnect(server_name): return True, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) # Mock the interruptible method to execute the coroutine directly without interruption async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() # Execute - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) # Assertions assert "Removed server: test-server" in result @@ -81,6 +83,7 @@ async def test_remove_mcp_tool_non_existent(): """Test removing a non-existent MCP server.""" # Setup coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() # Create a mock server that exists (to bypass the 'no servers' check) existing_server = MagicMock() @@ -90,7 +93,7 @@ async def test_remove_mcp_tool_non_existent(): coder.mcp_manager.get_server.return_value = None # Execute - result = await RemoveMcpTool.execute(coder, ["non-existent-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["non-existent-server"]) # Assertions assert "MCP server non-existent-server does not exist." in result @@ -100,6 +103,7 @@ async def test_remove_mcp_tool_non_existent(): async def test_remove_mcp_tool_not_connected(): """Test removing a server that is not connected.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -107,7 +111,7 @@ async def test_remove_mcp_tool_not_connected(): coder.mcp_manager.get_server.return_value = server coder.mcp_manager.connected_servers = {} - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Server test-server is not currently connected." in result @@ -116,6 +120,7 @@ async def test_remove_mcp_tool_not_connected(): async def test_remove_mcp_tool_wildcard(): """Test removing all servers with wildcard '*'.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server1 = MagicMock() @@ -124,21 +129,24 @@ async def test_remove_mcp_tool_wildcard(): server2.name = "server2" coder.mcp_manager.servers = [server1, server2] coder.mcp_manager.connected_servers = {"server1": server1, "server2": server2} + coder.mcp_manager.get_server.side_effect = lambda name: next( + (s for s in [server1, server2] if s.name == name), None + ) # Mock disconnect_server as an AsyncMock that returns (True, False) async def mock_disconnect(server_name): return True, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["*"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in result assert "Removed server: server2" in result @@ -148,6 +156,7 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_interrupted(): """Test when removal is interrupted.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -158,7 +167,7 @@ async def test_remove_mcp_tool_interrupted(): async def mock_disconnect(server_name): return False, True - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): return False, True @@ -166,7 +175,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Interrupted: test-server" in result @@ -175,6 +184,7 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_failed(): """Test when removal fails.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server = MagicMock() server.name = "test-server" @@ -185,15 +195,15 @@ async def test_remove_mcp_tool_failed(): async def mock_disconnect(server_name): return False, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, ["test-server"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) assert "Unable to remove server: test-server" in result @@ -202,10 +212,11 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_no_servers_configured(): """Test when no MCP servers are configured at all.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() coder.mcp_manager.servers = [] - result = await RemoveMcpTool.execute(coder, servers=["test"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["test"]) assert result == "No MCP servers are configured." @@ -214,6 +225,7 @@ async def test_remove_mcp_tool_no_servers_configured(): async def test_remove_mcp_tool_mixed_results(): """Test mixed success/failure results.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server1 = MagicMock() server1.name = "server1" @@ -233,15 +245,15 @@ async def mock_disconnect(server_name): call_count += 1 return result - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): - return await coro, False + return await coro coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, servers=["server1", "server2"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["server1", "server2"]) assert "Removed server: server1" in result assert "Unable to remove server: server2" in result @@ -251,6 +263,7 @@ async def mock_interruptible(coro, event): async def test_remove_mcp_tool_dictionary_iteration_fix(): """Test that dictionary iteration bug is fixed - iterates over keys correctly.""" coder = MagicMock() + coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() server1 = MagicMock() server1.name = "server1" @@ -265,7 +278,7 @@ async def test_remove_mcp_tool_dictionary_iteration_fix(): async def mock_disconnect(server_name): return True, False - coder.mcp_manager.disconnect_server = mock_disconnect + coder.mcp_manager.disconnect_server = AsyncMock(side_effect=mock_disconnect) async def mock_interruptible(coro, event): return await coro, False @@ -273,7 +286,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await RemoveMcpTool.execute(coder, servers=["*"]) + result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) # Should successfully remove both servers using dictionary keys assert "Removed server: server1" in result From 7cf2971243009a969e553f04198c24abf52a9d05 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Jun 2026 00:23:32 -0400 Subject: [PATCH 41/41] #579: Editable on non-existent file creates it --- cecli/tools/resource_manager.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cecli/tools/resource_manager.py b/cecli/tools/resource_manager.py index 4a47f7d1de0..f9c634ffc01 100644 --- a/cecli/tools/resource_manager.py +++ b/cecli/tools/resource_manager.py @@ -214,7 +214,15 @@ async def execute( for f in view_files: messages.append(cls._view(coder, f)) for f in editable_files: - messages.append(cls._editable(coder, f)) + try: + abs_path = coder.abs_root_path(f) + except Exception: + abs_path = None + if abs_path is not None and not os.path.isfile(abs_path): + coder.io.tool_output(f"ℹ️ `{f}` missing on disk — using **create** instead of add") + messages.append(cls._create(coder, f)) + else: + messages.append(cls._editable(coder, f)) for key in stop_keys: messages.append(cls._stop_command(coder, key)) for skill_name in load_skill_names: