From 6e522aa5e113491b7d63651a93331f08906cbf55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:09:13 +0000 Subject: [PATCH 1/4] Add workspace-backed startup and provider config Co-authored-by: alanwang233233 <107164653+alanwang233233@users.noreply.github.com> --- src/mitocode/__main__.py | 219 +++++++++++++----- .../backend/agent/agent_orchestrator.py | 12 +- src/mitocode/tui/ui_main.py | 65 +++++- test/test_main_entrypoint.py | 40 ++++ test/test_orchestrator_and_bridge.py | 41 ++++ 5 files changed, 322 insertions(+), 55 deletions(-) create mode 100644 test/test_main_entrypoint.py diff --git a/src/mitocode/__main__.py b/src/mitocode/__main__.py index 0a0fa30..12936ed 100644 --- a/src/mitocode/__main__.py +++ b/src/mitocode/__main__.py @@ -1,96 +1,209 @@ -"""WIP""" +"""CLI entrypoint for running the MitoCode TUI.""" import argparse import asyncio -from pyexpat.errors import messages - -from httpx import stream +import json +import os +from pathlib import Path +from typing import Any, Dict +from mitocode import APP_VERSION, MITOCODE_CONFIG_PATH from mitocode.backend.agent.agent_orchestrator import AgentOrchestrator -from mitocode.backend.chat_provider.scenario_provider import ScenarioProvider from mitocode.backend.chat_provider.chat_provider_openai import ChatProviderOpenAI +from mitocode.backend.chat_provider.scenario_provider import ScenarioProvider +from mitocode.backend.chat_provider.sample_chat_provider import SampleChatProvider from mitocode.backend.tool.tool_manager import ToolManager +from mitocode.backend.workspace.workspace import Workspace from mitocode.tui.ui_main import VibeFrontend -def _register_tools(tool_manager: ToolManager): - """Register built-in demo tools (bash, read_file, write_file) on the manager.""" +def _register_tools(tool_manager: ToolManager, workspace: Workspace) -> None: + """Register workspace-backed tools (bash, read_file, write_file).""" @tool_manager.tool( - human_doc="Run a shell command", - agent_doc="Execute a bash command and return its output", + human_doc="Run a shell command inside workspace", + agent_doc="Execute a shell command in the workspace directory and return output", display_action="bash", ) async def bash(command: str) -> str: - return f"$ {command}\n[bash] command executed successfully" + proc = await asyncio.create_subprocess_shell( + command, + cwd=str(workspace.path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_bytes, stderr_bytes = await proc.communicate() + stdout = stdout_bytes.decode("utf-8", errors="replace") + stderr = stderr_bytes.decode("utf-8", errors="replace") + output = stdout if stdout else "(no stdout)" + if stderr: + output += f"\n[stderr]\n{stderr}" + return ( + f"$ {command}\n[cwd] {workspace.path}\n[exit_code] {proc.returncode}\n{output}" + ) @tool_manager.tool( human_doc="Read file content", - agent_doc="Read the full content of a file at the given path", + agent_doc="Read UTF-8 text file content from a workspace-relative path", display_action="file.read", display_param="file_path", ) async def read_file(file_path: str) -> str: - return ( - "def bubble_sort(arr):\n" - " n = len(arr)\n" - " for i in range(n):\n" - " swapped = False\n" - " for j in range(0, n - i - 1):\n" - " if arr[j] > arr[j + 1]:\n" - " arr[j], arr[j + 1] = arr[j + 1], arr[j]\n" - " swapped = True\n" - " if not swapped:\n" - " break\n" - " return arr\n" - ) + safe_path = workspace._validate_within_workspace(Path(file_path)) + if not safe_path.exists(): + raise FileNotFoundError(f"File not found: {safe_path}") + if not safe_path.is_file(): + raise IsADirectoryError(f"Path is not a file: {safe_path}") + return await asyncio.to_thread(safe_path.read_text, encoding="utf-8") @tool_manager.tool( human_doc="Write content to a file", - agent_doc="Create or overwrite a file with the given content", + agent_doc="Write UTF-8 content into a workspace-relative file path", display_action="file.write", display_param="file_path", ) async def write_file(file_path: str, content: str) -> str: - return f"Successfully wrote {len(content)} bytes to {file_path}" + safe_path = workspace._validate_within_workspace(Path(file_path)) + await asyncio.to_thread(safe_path.parent.mkdir, parents=True, exist_ok=True) + await asyncio.to_thread(safe_path.write_text, content, encoding="utf-8") + return f"Successfully wrote {len(content)} bytes to {safe_path}" + + +def _parse_provider_settings(raw: str) -> Dict[str, Any]: + """Parse user-provided provider settings JSON string.""" + if not raw: + return {} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("--provider-settings must be a JSON object") + return parsed + + +def _default_provider_kind() -> str: + return ( + "openai" + if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") + else "scenario" + ) -def main(): +def _build_provider(args: argparse.Namespace) -> SampleChatProvider: + """Build and initialize the provider from CLI arguments.""" + if args.provider == "scenario": + settings = {"delay": args.speed} + settings.update(_parse_provider_settings(args.provider_settings)) + return asyncio.run( + ScenarioProvider.factory( + default_model=args.model or "scenario-model", + default_model_list=["scenario-model"], + default_messages=None, + default_tools=None, + stream=True, + settings=settings, + delay=settings.get("delay", args.speed), + ) + ) + + api_key = args.api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") + if not api_key: + raise ValueError("OpenAI provider requires --api-key or OPENAI_API_KEY/OPENROUTER_API_KEY") + + settings: Dict[str, Any] = { + "api_key": api_key, + "timeout": args.timeout, + } + if args.base_url: + settings["base_url"] = args.base_url + settings.update(_parse_provider_settings(args.provider_settings)) + + default_model = args.model or "gpt-4o-mini" + return asyncio.run( + ChatProviderOpenAI.factory( + default_model=default_model, + default_model_list=[default_model], + default_messages=None, + default_tools=None, + stream=True, + settings=settings, + ) + ) + + +def _init_workspace(path_arg: str, name: str) -> Workspace: + """Initialize or create workspace at the given path.""" + workspace_root = Path(path_arg).expanduser().resolve() + workspace_root.mkdir(parents=True, exist_ok=True) + + config_file = workspace_root / MITOCODE_CONFIG_PATH / "config.json" + if config_file.exists(): + return Workspace.init_from_path(workspace_root) + + return Workspace.new_workspace( + workspace_path=workspace_root, + name=name, + description=f"MitoCode workspace (app v{APP_VERSION})", + override=False, + ) + + +def main() -> None: """Parse CLI arguments and launch the MitoCode TUI application.""" parser = argparse.ArgumentParser(description="MitoCode") parser.add_argument( "--speed", type=float, default=0.03, - help="Delay between chunks in seconds (default: 0.03)", + help="Delay between scenario provider chunks in seconds (default: 0.03)", + ) + parser.add_argument( + "--workspace", + type=str, + default=".", + help="Workspace root path (default: current directory)", + ) + parser.add_argument( + "--workspace-name", + type=str, + default="mitocode-workspace", + help="Workspace name for first-time initialization", + ) + parser.add_argument( + "--provider", + choices=["scenario", "openai"], + default=_default_provider_kind(), + help="LLM provider backend", + ) + parser.add_argument("--model", type=str, default="", help="Provider model name") + parser.add_argument( + "--api-key", + type=str, + default="", + help="OpenAI/OpenRouter API key (or use OPENAI_API_KEY / OPENROUTER_API_KEY)", + ) + parser.add_argument( + "--base-url", + type=str, + default=os.getenv("OPENAI_BASE_URL", ""), + help="OpenAI-compatible API base URL override", + ) + parser.add_argument( + "--timeout", + type=float, + default=60, + help="Provider request timeout in seconds", + ) + parser.add_argument( + "--provider-settings", + type=str, + default="", + help="Extra provider settings as JSON object", ) args = parser.parse_args() + workspace = _init_workspace(args.workspace, args.workspace_name) tool_manager = ToolManager() - _register_tools(tool_manager) - - provider = asyncio.run(ChatProviderOpenAI.factory( - default_model= "nvidia/nemotron-3.5-lightning:free", - default_model_list= None, - default_messages= None, - default_tools= None, - stream= True, - settings={ - "api_key": "sk-别看了这次我不会再把key提交到仓库了!", - "base_url": "https://openrouter.ai/api/v1", - "timeout": 60, - }) - ) - - # print(asyncio.run(provider.chat( - # model ="nvidia/nemotron-3.5-lightning:free", - # tools =None, - # messages = [ - # {"role": "system", "content": "You are a helpful assistant."}, - # {"role": "user", "content": "Hello!"} - # ], - # stream = False - # ))) + _register_tools(tool_manager, workspace) + provider = _build_provider(args) orchestrator = AgentOrchestrator( tool_manager=tool_manager, default_provider=provider @@ -98,7 +211,7 @@ def main(): asyncio.run( orchestrator.create_agent( name="Main", - description="Chief orchestrator", + description=f"Workspace: {workspace.path}", ) ) diff --git a/src/mitocode/backend/agent/agent_orchestrator.py b/src/mitocode/backend/agent/agent_orchestrator.py index 0f39318..70a46f1 100644 --- a/src/mitocode/backend/agent/agent_orchestrator.py +++ b/src/mitocode/backend/agent/agent_orchestrator.py @@ -264,8 +264,18 @@ async def _run_session(self, agent_id: int) -> None: except asyncio.CancelledError: session.state = AgentState.CANCELLED raise - except Exception: # pylint: disable=broad-exception-caught + except Exception as e: # pylint: disable=broad-exception-caught session.state = AgentState.ERROR + await self.event_queue.put( + ( + agent_id, + { + "type": "error", + "agent_id": agent_id, + "message": str(e) or "Unknown runtime error", + }, + ) + ) finally: if self._tasks.get(agent_id) is asyncio.current_task(): self._tasks[agent_id] = None diff --git a/src/mitocode/tui/ui_main.py b/src/mitocode/tui/ui_main.py index 5aaad3c..27e8296 100644 --- a/src/mitocode/tui/ui_main.py +++ b/src/mitocode/tui/ui_main.py @@ -360,6 +360,47 @@ def render(self): ) +class WelcomePanel(Static): + """Startup panel shown before first message is sent.""" + + panel_type = "welcome" + + def render(self): + return Panel( + RichMarkdown( + "## 欢迎使用 MitoCode TUI\n\n" + "- 在左侧选择 Agent\n" + "- 在下方输入框发送消息\n" + "- 使用 `Quick create` 快速创建子 Agent\n" + "- `F12` 打开调试日志" + ), + title="[bold cyan]Welcome[/bold cyan]", + border_style="", + box=box.SIMPLE, + padding=(1, 1), + ) + + +class ErrorPanel(Static): + """Error panel used for runtime/system errors.""" + + panel_type = "error" + + def __init__(self, text: str): + super().__init__() + self.error_text = text + + def render(self): + return Panel( + self.error_text, + title="[bold red]Error[/bold red]", + border_style="red", + box=box.SIMPLE, + style="on #3a1f1f", + padding=(0, 1), + ) + + class AgentView(VerticalScroll): """Scrollable per-agent chat view assembling message and tool panels. @@ -374,11 +415,19 @@ def __init__(self, agent_id: int, agent_name: str = ""): self.border_title = f"Agent {agent_id}" self.styles.border = ("solid", "cyan") self._streaming_panel: Optional[Static] = None + self._welcome_panel: Optional[WelcomePanel] = None - def on_mount(self): + async def on_mount(self): """Enable auto vertical scrolling with a stable scrollbar gutter.""" self.styles.overflow_y = "auto" self.styles.scrollbar_gutter = "stable" + self._welcome_panel = WelcomePanel() + await self.mount(self._welcome_panel) + + async def _hide_welcome(self): + if self._welcome_panel is not None: + await self._welcome_panel.remove() + self._welcome_panel = None def set_active(self, active: bool): """Highlight (or reset) the border indicating the focused agent.""" @@ -405,6 +454,7 @@ def _scroll_if_was_at_bottom(self, was_at_bottom: bool): async def append_user_message(self, text: str) -> None: """Mount a new user-message panel and scroll to it.""" + await self._hide_welcome() self._streaming_panel = None panel = UserMessagePanel(text) await self.mount(panel) @@ -417,6 +467,7 @@ async def append_plain_text(self, delta: str) -> None: plain text, otherwise appends in place; keeps the view pinned to the bottom only if the user was already there. """ + await self._hide_welcome() if self._last_panel_type != "plain_text": self._streaming_panel = None panel = PlainTextPanel() @@ -430,6 +481,7 @@ async def append_plain_text(self, delta: str) -> None: async def append_reasoning(self, delta: str) -> None: """Stream reasoning deltas into a shared reasoning-text panel.""" + await self._hide_welcome() if self._last_panel_type != "reasoning_text": self._streaming_panel = None panel = ReasoningTextPanel() @@ -450,6 +502,7 @@ async def start_tool_call( guessed from the target file extension; other actions get a compact one-line preview panel. """ + await self._hide_welcome() self._streaming_panel = None if action in ("file.write",): @@ -498,6 +551,14 @@ async def finish_tool_call(self, result: str, success: bool = True) -> None: self._streaming_panel = None self._scroll_to_bottom() + async def append_error(self, message: str) -> None: + """Mount a runtime error panel.""" + await self._hide_welcome() + self._streaming_panel = None + panel = ErrorPanel(message) + await self.mount(panel) + self._scroll_to_bottom() + class DynamicGrid(Grid): """Grid container arranging agent views: one large + up to three small. @@ -874,6 +935,8 @@ async def _handle_agent_event(self, agent_id: int, event: dict) -> None: result = event.get("result", "") success = event.get("success", False) await agent_view.finish_tool_call(result, success) + elif event_type == "error": + await agent_view.append_error(event.get("message", "Unknown error")) # ------------------------------------------------------------------ # Quick create & chat input diff --git a/test/test_main_entrypoint.py b/test/test_main_entrypoint.py new file mode 100644 index 0000000..3484e45 --- /dev/null +++ b/test/test_main_entrypoint.py @@ -0,0 +1,40 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from mitocode.__main__ import ( # noqa: E402 + _default_provider_kind, + _init_workspace, + _parse_provider_settings, +) + + +def test_parse_provider_settings_accepts_empty(): + assert _parse_provider_settings("") == {} + + +def test_parse_provider_settings_rejects_non_object(): + with pytest.raises(ValueError): + _parse_provider_settings('["bad"]') + + +def test_init_workspace_creates_then_loads(tmp_path): + root = tmp_path / "ws" + ws1 = _init_workspace(str(root), "demo") + assert ws1.config["name"] == "demo" + assert ws1.config_file.exists() + + ws2 = _init_workspace(str(root), "ignored") + assert ws2.config["name"] == "demo" + + +def test_default_provider_kind_uses_env(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + assert _default_provider_kind() == "scenario" + + monkeypatch.setenv("OPENAI_API_KEY", "dummy") + assert _default_provider_kind() == "openai" diff --git a/test/test_orchestrator_and_bridge.py b/test/test_orchestrator_and_bridge.py index 00b4e6d..c0a3520 100644 --- a/test/test_orchestrator_and_bridge.py +++ b/test/test_orchestrator_and_bridge.py @@ -54,6 +54,29 @@ async def _stream(): return _stream() +class FailingProvider(_SampleChatProvider): + def __init__(self): + self.stream = True + + @staticmethod + def get_model_list(): + return ["failing-model"] + + @classmethod + def get_settings_schema(cls): + return {"type": "object", "properties": {}, "additionalProperties": True} + + @staticmethod + def check_settings_schema(settings): + return True + + def init(self): + pass + + async def chat(self, tools=None, messages=None, **kwargs): + raise RuntimeError("provider exploded") + + @pytest.mark.asyncio async def test_orchestrator_create_and_send_message(): orchestrator = AgentOrchestrator(default_provider=EchoProvider()) @@ -167,6 +190,24 @@ async def test_create_agent_alias_matches_create_session_id(): assert session.name == "Alias" +@pytest.mark.asyncio +async def test_orchestrator_emits_error_event_on_runtime_failure(): + orchestrator = AgentOrchestrator(default_provider=FailingProvider()) + agent_id = await orchestrator.create_agent(name="Broken") + task = await orchestrator.send_message(agent_id, "hello") + assert task is not None + await task + + got_error_event = False + while not orchestrator.event_queue.empty(): + aid, event = await orchestrator.event_queue.get() + if aid == agent_id and event.get("type") == "error": + got_error_event = True + assert "provider exploded" in event.get("message", "") + break + assert got_error_event is True + + def test_event_bridge_token(): bridge = EventBridge() result = bridge.adapt({"event": "agent.text", "delta": "hello"}, agent_id=1) From b28bff249f8e0ae8a6129f15bed9aa2194d75757 Mon Sep 17 00:00:00 2001 From: Wang Allen Date: Sat, 10 Oct 2026 10:41:37 +0800 Subject: [PATCH 2/4] feat(mcp): add MCP server management --- .../backend/agent/agent_orchestrator.py | 12 +--- src/mitocode/tui/ui_main.py | 65 +------------------ test/test_main_entrypoint.py | 40 ------------ test/test_orchestrator_and_bridge.py | 41 ------------ 4 files changed, 2 insertions(+), 156 deletions(-) delete mode 100644 test/test_main_entrypoint.py diff --git a/src/mitocode/backend/agent/agent_orchestrator.py b/src/mitocode/backend/agent/agent_orchestrator.py index 70a46f1..0f39318 100644 --- a/src/mitocode/backend/agent/agent_orchestrator.py +++ b/src/mitocode/backend/agent/agent_orchestrator.py @@ -264,18 +264,8 @@ async def _run_session(self, agent_id: int) -> None: except asyncio.CancelledError: session.state = AgentState.CANCELLED raise - except Exception as e: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-exception-caught session.state = AgentState.ERROR - await self.event_queue.put( - ( - agent_id, - { - "type": "error", - "agent_id": agent_id, - "message": str(e) or "Unknown runtime error", - }, - ) - ) finally: if self._tasks.get(agent_id) is asyncio.current_task(): self._tasks[agent_id] = None diff --git a/src/mitocode/tui/ui_main.py b/src/mitocode/tui/ui_main.py index 27e8296..5aaad3c 100644 --- a/src/mitocode/tui/ui_main.py +++ b/src/mitocode/tui/ui_main.py @@ -360,47 +360,6 @@ def render(self): ) -class WelcomePanel(Static): - """Startup panel shown before first message is sent.""" - - panel_type = "welcome" - - def render(self): - return Panel( - RichMarkdown( - "## 欢迎使用 MitoCode TUI\n\n" - "- 在左侧选择 Agent\n" - "- 在下方输入框发送消息\n" - "- 使用 `Quick create` 快速创建子 Agent\n" - "- `F12` 打开调试日志" - ), - title="[bold cyan]Welcome[/bold cyan]", - border_style="", - box=box.SIMPLE, - padding=(1, 1), - ) - - -class ErrorPanel(Static): - """Error panel used for runtime/system errors.""" - - panel_type = "error" - - def __init__(self, text: str): - super().__init__() - self.error_text = text - - def render(self): - return Panel( - self.error_text, - title="[bold red]Error[/bold red]", - border_style="red", - box=box.SIMPLE, - style="on #3a1f1f", - padding=(0, 1), - ) - - class AgentView(VerticalScroll): """Scrollable per-agent chat view assembling message and tool panels. @@ -415,19 +374,11 @@ def __init__(self, agent_id: int, agent_name: str = ""): self.border_title = f"Agent {agent_id}" self.styles.border = ("solid", "cyan") self._streaming_panel: Optional[Static] = None - self._welcome_panel: Optional[WelcomePanel] = None - async def on_mount(self): + def on_mount(self): """Enable auto vertical scrolling with a stable scrollbar gutter.""" self.styles.overflow_y = "auto" self.styles.scrollbar_gutter = "stable" - self._welcome_panel = WelcomePanel() - await self.mount(self._welcome_panel) - - async def _hide_welcome(self): - if self._welcome_panel is not None: - await self._welcome_panel.remove() - self._welcome_panel = None def set_active(self, active: bool): """Highlight (or reset) the border indicating the focused agent.""" @@ -454,7 +405,6 @@ def _scroll_if_was_at_bottom(self, was_at_bottom: bool): async def append_user_message(self, text: str) -> None: """Mount a new user-message panel and scroll to it.""" - await self._hide_welcome() self._streaming_panel = None panel = UserMessagePanel(text) await self.mount(panel) @@ -467,7 +417,6 @@ async def append_plain_text(self, delta: str) -> None: plain text, otherwise appends in place; keeps the view pinned to the bottom only if the user was already there. """ - await self._hide_welcome() if self._last_panel_type != "plain_text": self._streaming_panel = None panel = PlainTextPanel() @@ -481,7 +430,6 @@ async def append_plain_text(self, delta: str) -> None: async def append_reasoning(self, delta: str) -> None: """Stream reasoning deltas into a shared reasoning-text panel.""" - await self._hide_welcome() if self._last_panel_type != "reasoning_text": self._streaming_panel = None panel = ReasoningTextPanel() @@ -502,7 +450,6 @@ async def start_tool_call( guessed from the target file extension; other actions get a compact one-line preview panel. """ - await self._hide_welcome() self._streaming_panel = None if action in ("file.write",): @@ -551,14 +498,6 @@ async def finish_tool_call(self, result: str, success: bool = True) -> None: self._streaming_panel = None self._scroll_to_bottom() - async def append_error(self, message: str) -> None: - """Mount a runtime error panel.""" - await self._hide_welcome() - self._streaming_panel = None - panel = ErrorPanel(message) - await self.mount(panel) - self._scroll_to_bottom() - class DynamicGrid(Grid): """Grid container arranging agent views: one large + up to three small. @@ -935,8 +874,6 @@ async def _handle_agent_event(self, agent_id: int, event: dict) -> None: result = event.get("result", "") success = event.get("success", False) await agent_view.finish_tool_call(result, success) - elif event_type == "error": - await agent_view.append_error(event.get("message", "Unknown error")) # ------------------------------------------------------------------ # Quick create & chat input diff --git a/test/test_main_entrypoint.py b/test/test_main_entrypoint.py deleted file mode 100644 index 3484e45..0000000 --- a/test/test_main_entrypoint.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import sys - -import pytest - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - -from mitocode.__main__ import ( # noqa: E402 - _default_provider_kind, - _init_workspace, - _parse_provider_settings, -) - - -def test_parse_provider_settings_accepts_empty(): - assert _parse_provider_settings("") == {} - - -def test_parse_provider_settings_rejects_non_object(): - with pytest.raises(ValueError): - _parse_provider_settings('["bad"]') - - -def test_init_workspace_creates_then_loads(tmp_path): - root = tmp_path / "ws" - ws1 = _init_workspace(str(root), "demo") - assert ws1.config["name"] == "demo" - assert ws1.config_file.exists() - - ws2 = _init_workspace(str(root), "ignored") - assert ws2.config["name"] == "demo" - - -def test_default_provider_kind_uses_env(monkeypatch): - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - assert _default_provider_kind() == "scenario" - - monkeypatch.setenv("OPENAI_API_KEY", "dummy") - assert _default_provider_kind() == "openai" diff --git a/test/test_orchestrator_and_bridge.py b/test/test_orchestrator_and_bridge.py index c0a3520..00b4e6d 100644 --- a/test/test_orchestrator_and_bridge.py +++ b/test/test_orchestrator_and_bridge.py @@ -54,29 +54,6 @@ async def _stream(): return _stream() -class FailingProvider(_SampleChatProvider): - def __init__(self): - self.stream = True - - @staticmethod - def get_model_list(): - return ["failing-model"] - - @classmethod - def get_settings_schema(cls): - return {"type": "object", "properties": {}, "additionalProperties": True} - - @staticmethod - def check_settings_schema(settings): - return True - - def init(self): - pass - - async def chat(self, tools=None, messages=None, **kwargs): - raise RuntimeError("provider exploded") - - @pytest.mark.asyncio async def test_orchestrator_create_and_send_message(): orchestrator = AgentOrchestrator(default_provider=EchoProvider()) @@ -190,24 +167,6 @@ async def test_create_agent_alias_matches_create_session_id(): assert session.name == "Alias" -@pytest.mark.asyncio -async def test_orchestrator_emits_error_event_on_runtime_failure(): - orchestrator = AgentOrchestrator(default_provider=FailingProvider()) - agent_id = await orchestrator.create_agent(name="Broken") - task = await orchestrator.send_message(agent_id, "hello") - assert task is not None - await task - - got_error_event = False - while not orchestrator.event_queue.empty(): - aid, event = await orchestrator.event_queue.get() - if aid == agent_id and event.get("type") == "error": - got_error_event = True - assert "provider exploded" in event.get("message", "") - break - assert got_error_event is True - - def test_event_bridge_token(): bridge = EventBridge() result = bridge.adapt({"event": "agent.text", "delta": "hello"}, agent_id=1) From 5cbed57d5f2f86562dc4c32f58aab04f8422fce0 Mon Sep 17 00:00:00 2001 From: Wang Allen Date: Wed, 14 Oct 2026 10:11:20 +0800 Subject: [PATCH 3/4] refactor: simplify __main__.py --- src/mitocode/__main__.py | 219 ++++++++++----------------------------- 1 file changed, 53 insertions(+), 166 deletions(-) diff --git a/src/mitocode/__main__.py b/src/mitocode/__main__.py index 12936ed..0a0fa30 100644 --- a/src/mitocode/__main__.py +++ b/src/mitocode/__main__.py @@ -1,209 +1,96 @@ -"""CLI entrypoint for running the MitoCode TUI.""" +"""WIP""" import argparse import asyncio -import json -import os -from pathlib import Path -from typing import Any, Dict +from pyexpat.errors import messages + +from httpx import stream -from mitocode import APP_VERSION, MITOCODE_CONFIG_PATH from mitocode.backend.agent.agent_orchestrator import AgentOrchestrator -from mitocode.backend.chat_provider.chat_provider_openai import ChatProviderOpenAI from mitocode.backend.chat_provider.scenario_provider import ScenarioProvider -from mitocode.backend.chat_provider.sample_chat_provider import SampleChatProvider +from mitocode.backend.chat_provider.chat_provider_openai import ChatProviderOpenAI from mitocode.backend.tool.tool_manager import ToolManager -from mitocode.backend.workspace.workspace import Workspace from mitocode.tui.ui_main import VibeFrontend -def _register_tools(tool_manager: ToolManager, workspace: Workspace) -> None: - """Register workspace-backed tools (bash, read_file, write_file).""" +def _register_tools(tool_manager: ToolManager): + """Register built-in demo tools (bash, read_file, write_file) on the manager.""" @tool_manager.tool( - human_doc="Run a shell command inside workspace", - agent_doc="Execute a shell command in the workspace directory and return output", + human_doc="Run a shell command", + agent_doc="Execute a bash command and return its output", display_action="bash", ) async def bash(command: str) -> str: - proc = await asyncio.create_subprocess_shell( - command, - cwd=str(workspace.path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout_bytes, stderr_bytes = await proc.communicate() - stdout = stdout_bytes.decode("utf-8", errors="replace") - stderr = stderr_bytes.decode("utf-8", errors="replace") - output = stdout if stdout else "(no stdout)" - if stderr: - output += f"\n[stderr]\n{stderr}" - return ( - f"$ {command}\n[cwd] {workspace.path}\n[exit_code] {proc.returncode}\n{output}" - ) + return f"$ {command}\n[bash] command executed successfully" @tool_manager.tool( human_doc="Read file content", - agent_doc="Read UTF-8 text file content from a workspace-relative path", + agent_doc="Read the full content of a file at the given path", display_action="file.read", display_param="file_path", ) async def read_file(file_path: str) -> str: - safe_path = workspace._validate_within_workspace(Path(file_path)) - if not safe_path.exists(): - raise FileNotFoundError(f"File not found: {safe_path}") - if not safe_path.is_file(): - raise IsADirectoryError(f"Path is not a file: {safe_path}") - return await asyncio.to_thread(safe_path.read_text, encoding="utf-8") + return ( + "def bubble_sort(arr):\n" + " n = len(arr)\n" + " for i in range(n):\n" + " swapped = False\n" + " for j in range(0, n - i - 1):\n" + " if arr[j] > arr[j + 1]:\n" + " arr[j], arr[j + 1] = arr[j + 1], arr[j]\n" + " swapped = True\n" + " if not swapped:\n" + " break\n" + " return arr\n" + ) @tool_manager.tool( human_doc="Write content to a file", - agent_doc="Write UTF-8 content into a workspace-relative file path", + agent_doc="Create or overwrite a file with the given content", display_action="file.write", display_param="file_path", ) async def write_file(file_path: str, content: str) -> str: - safe_path = workspace._validate_within_workspace(Path(file_path)) - await asyncio.to_thread(safe_path.parent.mkdir, parents=True, exist_ok=True) - await asyncio.to_thread(safe_path.write_text, content, encoding="utf-8") - return f"Successfully wrote {len(content)} bytes to {safe_path}" - - -def _parse_provider_settings(raw: str) -> Dict[str, Any]: - """Parse user-provided provider settings JSON string.""" - if not raw: - return {} - parsed = json.loads(raw) - if not isinstance(parsed, dict): - raise ValueError("--provider-settings must be a JSON object") - return parsed - - -def _default_provider_kind() -> str: - return ( - "openai" - if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") - else "scenario" - ) + return f"Successfully wrote {len(content)} bytes to {file_path}" -def _build_provider(args: argparse.Namespace) -> SampleChatProvider: - """Build and initialize the provider from CLI arguments.""" - if args.provider == "scenario": - settings = {"delay": args.speed} - settings.update(_parse_provider_settings(args.provider_settings)) - return asyncio.run( - ScenarioProvider.factory( - default_model=args.model or "scenario-model", - default_model_list=["scenario-model"], - default_messages=None, - default_tools=None, - stream=True, - settings=settings, - delay=settings.get("delay", args.speed), - ) - ) - - api_key = args.api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") - if not api_key: - raise ValueError("OpenAI provider requires --api-key or OPENAI_API_KEY/OPENROUTER_API_KEY") - - settings: Dict[str, Any] = { - "api_key": api_key, - "timeout": args.timeout, - } - if args.base_url: - settings["base_url"] = args.base_url - settings.update(_parse_provider_settings(args.provider_settings)) - - default_model = args.model or "gpt-4o-mini" - return asyncio.run( - ChatProviderOpenAI.factory( - default_model=default_model, - default_model_list=[default_model], - default_messages=None, - default_tools=None, - stream=True, - settings=settings, - ) - ) - - -def _init_workspace(path_arg: str, name: str) -> Workspace: - """Initialize or create workspace at the given path.""" - workspace_root = Path(path_arg).expanduser().resolve() - workspace_root.mkdir(parents=True, exist_ok=True) - - config_file = workspace_root / MITOCODE_CONFIG_PATH / "config.json" - if config_file.exists(): - return Workspace.init_from_path(workspace_root) - - return Workspace.new_workspace( - workspace_path=workspace_root, - name=name, - description=f"MitoCode workspace (app v{APP_VERSION})", - override=False, - ) - - -def main() -> None: +def main(): """Parse CLI arguments and launch the MitoCode TUI application.""" parser = argparse.ArgumentParser(description="MitoCode") parser.add_argument( "--speed", type=float, default=0.03, - help="Delay between scenario provider chunks in seconds (default: 0.03)", - ) - parser.add_argument( - "--workspace", - type=str, - default=".", - help="Workspace root path (default: current directory)", - ) - parser.add_argument( - "--workspace-name", - type=str, - default="mitocode-workspace", - help="Workspace name for first-time initialization", - ) - parser.add_argument( - "--provider", - choices=["scenario", "openai"], - default=_default_provider_kind(), - help="LLM provider backend", - ) - parser.add_argument("--model", type=str, default="", help="Provider model name") - parser.add_argument( - "--api-key", - type=str, - default="", - help="OpenAI/OpenRouter API key (or use OPENAI_API_KEY / OPENROUTER_API_KEY)", - ) - parser.add_argument( - "--base-url", - type=str, - default=os.getenv("OPENAI_BASE_URL", ""), - help="OpenAI-compatible API base URL override", - ) - parser.add_argument( - "--timeout", - type=float, - default=60, - help="Provider request timeout in seconds", - ) - parser.add_argument( - "--provider-settings", - type=str, - default="", - help="Extra provider settings as JSON object", + help="Delay between chunks in seconds (default: 0.03)", ) args = parser.parse_args() - workspace = _init_workspace(args.workspace, args.workspace_name) tool_manager = ToolManager() - _register_tools(tool_manager, workspace) - provider = _build_provider(args) + _register_tools(tool_manager) + + provider = asyncio.run(ChatProviderOpenAI.factory( + default_model= "nvidia/nemotron-3.5-lightning:free", + default_model_list= None, + default_messages= None, + default_tools= None, + stream= True, + settings={ + "api_key": "sk-别看了这次我不会再把key提交到仓库了!", + "base_url": "https://openrouter.ai/api/v1", + "timeout": 60, + }) + ) + + # print(asyncio.run(provider.chat( + # model ="nvidia/nemotron-3.5-lightning:free", + # tools =None, + # messages = [ + # {"role": "system", "content": "You are a helpful assistant."}, + # {"role": "user", "content": "Hello!"} + # ], + # stream = False + # ))) orchestrator = AgentOrchestrator( tool_manager=tool_manager, default_provider=provider @@ -211,7 +98,7 @@ def main() -> None: asyncio.run( orchestrator.create_agent( name="Main", - description=f"Workspace: {workspace.path}", + description="Chief orchestrator", ) ) From be1d90937046906376418e07884e84a00430d5c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Oct 2026 02:44:04 +0000 Subject: [PATCH 4/4] ci: auto-format code --- src/mitocode/__main__.py | 24 ++++++++++--------- .../chat_provider/scenario_provider.py | 1 - 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/mitocode/__main__.py b/src/mitocode/__main__.py index 0a0fa30..f511246 100644 --- a/src/mitocode/__main__.py +++ b/src/mitocode/__main__.py @@ -69,17 +69,19 @@ def main(): tool_manager = ToolManager() _register_tools(tool_manager) - provider = asyncio.run(ChatProviderOpenAI.factory( - default_model= "nvidia/nemotron-3.5-lightning:free", - default_model_list= None, - default_messages= None, - default_tools= None, - stream= True, - settings={ - "api_key": "sk-别看了这次我不会再把key提交到仓库了!", - "base_url": "https://openrouter.ai/api/v1", - "timeout": 60, - }) + provider = asyncio.run( + ChatProviderOpenAI.factory( + default_model="nvidia/nemotron-3.5-lightning:free", + default_model_list=None, + default_messages=None, + default_tools=None, + stream=True, + settings={ + "api_key": "sk-别看了这次我不会再把key提交到仓库了!", + "base_url": "https://openrouter.ai/api/v1", + "timeout": 60, + }, + ) ) # print(asyncio.run(provider.chat( diff --git a/src/mitocode/backend/chat_provider/scenario_provider.py b/src/mitocode/backend/chat_provider/scenario_provider.py index e1fd5c0..fdb0804 100644 --- a/src/mitocode/backend/chat_provider/scenario_provider.py +++ b/src/mitocode/backend/chat_provider/scenario_provider.py @@ -12,7 +12,6 @@ from mitocode.backend.chat_provider.sample_chat_provider import _SampleChatProvider - def _make_chunk( content: Optional[str] = None, tool_calls: Optional[List[Any]] = None,