Spaces:
Running
Running
SoniCoder v2: Optimized code agent with Gemini CLI / Claude Code patterns, MCP support, multiple 1B models
Browse files- app.py +87 -30
- requirements.txt +10 -8
- sonicoder/__init__.py +12 -0
- sonicoder/agent/__init__.py +474 -0
- sonicoder/agent/protocol.py +106 -0
- sonicoder/config/__init__.py +225 -0
- sonicoder/context/__init__.py +154 -0
- sonicoder/execution/__init__.py +0 -0
- sonicoder/mcp/__init__.py +336 -0
- sonicoder/mcp/client_manager.py +0 -0
- sonicoder/model/__init__.py +0 -0
- sonicoder/model/inference.py +225 -0
- sonicoder/model/loader.py +181 -0
- sonicoder/plugins/__init__.py +0 -0
- sonicoder/policy/__init__.py +245 -0
- sonicoder/server/__init__.py +381 -0
- sonicoder/skills/__init__.py +0 -0
- sonicoder/tools/__init__.py +215 -0
- sonicoder/tools/bash.py +143 -0
- sonicoder/tools/fs.py +508 -0
- sonicoder/tools/todos.py +109 -0
- sonicoder/tools/web.py +163 -0
- static/index.html +568 -0
app.py
CHANGED
|
@@ -1,40 +1,97 @@
|
|
| 1 |
-
"""SoniCoder β
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
βββ model/loader.py Dual model loading & switching
|
| 15 |
-
βββ model/inference.py Streaming inference (text + VLM)
|
| 16 |
-
βββ execution/code_extractor.py Code extraction & language normalization
|
| 17 |
-
βββ execution/python_runner.py Sandboxed Python execution
|
| 18 |
-
βββ execution/gradio_runner.py Gradio app subprocess runner
|
| 19 |
-
βββ websearch/google_scraper.py Web search scraping (no API)
|
| 20 |
-
βββ huggingface/push.py HuggingFace Hub push & ZIP packaging
|
| 21 |
-
βββ server/chat_helpers.py Chat history & prompt building
|
| 22 |
-
βββ server/routes.py FastAPI / Gradio server routes
|
| 23 |
"""
|
| 24 |
|
| 25 |
from __future__ import annotations
|
| 26 |
|
|
|
|
| 27 |
import logging
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
from
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
logging.basicConfig(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
logger = logging.getLogger(__name__)
|
| 34 |
|
| 35 |
-
# Start loading default model in background
|
| 36 |
-
start_background_load()
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SoniCoder v2 β Entry point.
|
| 2 |
+
|
| 3 |
+
Optimized AI code writer agent with:
|
| 4 |
+
- Event-driven agent protocol (from Gemini CLI)
|
| 5 |
+
- ToolBuilder/ToolInvocation pattern (from Gemini CLI)
|
| 6 |
+
- MCP support (from both Gemini CLI & Claude Code)
|
| 7 |
+
- Multiple smart 1B models (Qwen2.5-Coder-1.5B, DeepSeek-Coder-1.3B, etc.)
|
| 8 |
+
- Policy/permission engine (from Gemini CLI)
|
| 9 |
+
- Context management
|
| 10 |
+
- Professional web UI
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import json
|
| 19 |
import logging
|
| 20 |
+
from pathlib import Path
|
| 21 |
|
| 22 |
+
# Ensure workspace exists
|
| 23 |
+
from sonicoder.config import WORKSPACE_ROOT, CONFIG_DIR
|
| 24 |
+
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
| 26 |
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=logging.INFO,
|
| 29 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 30 |
+
datefmt="%H:%M:%S",
|
| 31 |
+
)
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
def main():
|
| 36 |
+
logger.info("SoniCoder v2 starting...")
|
| 37 |
+
|
| 38 |
+
# Start loading default model in background
|
| 39 |
+
from sonicoder.model.loader import start_background_load
|
| 40 |
+
start_background_load()
|
| 41 |
+
logger.info("Background model loading started")
|
| 42 |
+
|
| 43 |
+
# Set up MCP servers from config
|
| 44 |
+
from sonicoder.mcp import get_mcp_manager
|
| 45 |
+
from sonicoder.config import load_settings
|
| 46 |
+
settings = load_settings()
|
| 47 |
+
if settings.mcp_servers:
|
| 48 |
+
mcp = get_mcp_manager()
|
| 49 |
+
mcp.configure(settings.mcp_servers)
|
| 50 |
+
results = mcp.connect_all()
|
| 51 |
+
for name, error in results.items():
|
| 52 |
+
if error:
|
| 53 |
+
logger.warning(f"MCP server '{name}' failed: {error}")
|
| 54 |
+
else:
|
| 55 |
+
logger.info(f"MCP server '{name}' connected")
|
| 56 |
+
|
| 57 |
+
# Create the Gradio app
|
| 58 |
+
from sonicoder.server import get_app, api_get_config
|
| 59 |
+
application = get_app()
|
| 60 |
+
|
| 61 |
+
# Inject runtime config into index.html and serve via custom route
|
| 62 |
+
config_json = api_get_config()
|
| 63 |
+
config_data = json.loads(config_json)
|
| 64 |
+
config_str = json.dumps(config_data, ensure_ascii=False)
|
| 65 |
+
|
| 66 |
+
index_path = Path(__file__).parent / "static" / "index.html"
|
| 67 |
+
if index_path.exists():
|
| 68 |
+
html = index_path.read_text(encoding="utf-8")
|
| 69 |
+
html = html.replace(
|
| 70 |
+
"__RUNTIME_CONFIG__ || {}",
|
| 71 |
+
config_str + " || {}"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Register a custom root route on the underlying FastAPI app
|
| 75 |
+
from fastapi import Response
|
| 76 |
+
from fastapi.responses import HTMLResponse
|
| 77 |
+
|
| 78 |
+
if hasattr(application, "app") and hasattr(application.app, "get"):
|
| 79 |
+
# Gradio 4.x: application.app is the FastAPI app (or Starlette)
|
| 80 |
+
fastapi_app = application.app
|
| 81 |
+
if hasattr(fastapi_app, "routes"):
|
| 82 |
+
@fastapi_app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 83 |
+
async def custom_root():
|
| 84 |
+
return Response(content=html, media_type="text/html")
|
| 85 |
+
logger.info("Custom root route registered")
|
| 86 |
+
|
| 87 |
+
logger.info("Launching web interface on http://0.0.0.0:7860")
|
| 88 |
+
application.launch(
|
| 89 |
+
show_error=True,
|
| 90 |
+
server_name="0.0.0.0",
|
| 91 |
+
server_port=7860,
|
| 92 |
+
share=False,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
main()
|
requirements.txt
CHANGED
|
@@ -1,15 +1,17 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
transformers>=4.45.0
|
| 3 |
torch>=2.1.0
|
| 4 |
accelerate>=0.25.0
|
| 5 |
huggingface_hub>=0.20.0
|
| 6 |
-
matplotlib>=3.8
|
| 7 |
requests>=2.31.0
|
| 8 |
beautifulsoup4>=4.12.0
|
| 9 |
Pillow>=10.0
|
| 10 |
-
|
| 11 |
-
#
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
#
|
| 15 |
-
#
|
|
|
|
| 1 |
+
# SoniCoder v2 β Optimized AI Code Agent
|
| 2 |
+
# Architecture inspired by Gemini CLI and Claude Code
|
| 3 |
+
|
| 4 |
+
gradio>=4.0.0
|
| 5 |
transformers>=4.45.0
|
| 6 |
torch>=2.1.0
|
| 7 |
accelerate>=0.25.0
|
| 8 |
huggingface_hub>=0.20.0
|
|
|
|
| 9 |
requests>=2.31.0
|
| 10 |
beautifulsoup4>=4.12.0
|
| 11 |
Pillow>=10.0
|
| 12 |
+
|
| 13 |
+
# Optional: MCP support
|
| 14 |
+
# mcp>=1.0.0
|
| 15 |
+
|
| 16 |
+
# Optional: VLM support
|
| 17 |
+
# torchvision>=0.16.0
|
sonicoder/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SoniCoder v2 β Optimized AI Code Writer Agent.
|
| 2 |
+
|
| 3 |
+
Architecture inspired by Gemini CLI and Claude Code.
|
| 4 |
+
- Event-driven agent protocol
|
| 5 |
+
- ToolBuilder/ToolInvocation pattern
|
| 6 |
+
- MCP (Model Context Protocol) support
|
| 7 |
+
- Multiple smart 1B models
|
| 8 |
+
- Policy/permission engine
|
| 9 |
+
- Context window management with compression
|
| 10 |
+
- Plugin system
|
| 11 |
+
"""
|
| 12 |
+
__version__ = "2.0.0"
|
sonicoder/agent/__init__.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent loop β the core agentic turn cycle.
|
| 2 |
+
|
| 3 |
+
Implements the full agent loop inspired by both Gemini CLI and Claude Code:
|
| 4 |
+
1. Build context (system prompt + tool schemas + history)
|
| 5 |
+
2. Call model
|
| 6 |
+
3. Parse tool calls from response
|
| 7 |
+
4. Check policy
|
| 8 |
+
5. Execute tools
|
| 9 |
+
6. Feed results back to model
|
| 10 |
+
7. Repeat until done or max iterations
|
| 11 |
+
|
| 12 |
+
Tool call format: ```tool\nname\nkey: value\nkey: value\n```
|
| 13 |
+
This is simpler than JSON function calling and works well with 1B models.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
import time
|
| 22 |
+
from typing import Any, Generator
|
| 23 |
+
|
| 24 |
+
from ..config import Settings, get_model_config
|
| 25 |
+
from ..inference import call_model_stream, TOOL_CALL_RE, THINKING_BLOCK_RE
|
| 26 |
+
from ..tools import ToolRegistry, ToolResult, get_tool_registry
|
| 27 |
+
from ..tools.fs import BUILTIN_TOOLS as FS_TOOLS
|
| 28 |
+
from ..tools.bash import BUILTIN_TOOLS as BASH_TOOLS
|
| 29 |
+
from ..tools.web import BUILTIN_TOOLS as WEB_TOOLS
|
| 30 |
+
from ..tools.todos import BUILTIN_TOOLS as TODO_TOOLS
|
| 31 |
+
from .protocol import AgentProtocol, AgentEvent, AgentEventType
|
| 32 |
+
from ..policy import get_policy_engine, ApprovalDecision
|
| 33 |
+
from ..mcp import get_mcp_manager
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
# ββ System Prompt ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
|
| 39 |
+
SYSTEM_PROMPT = """You are SoniCoder v2, an expert AI coding agent. You write clean, well-structured code and help users build projects.
|
| 40 |
+
|
| 41 |
+
## Capabilities
|
| 42 |
+
- Read, write, and edit files in the workspace
|
| 43 |
+
- Execute shell commands (bash)
|
| 44 |
+
- Search the web for information
|
| 45 |
+
- Manage task lists
|
| 46 |
+
- Use MCP tools when available
|
| 47 |
+
|
| 48 |
+
## Rules
|
| 49 |
+
1. Always use tools to interact with the file system. Never guess file contents.
|
| 50 |
+
2. When creating multi-file projects, write files one at a time with write_file.
|
| 51 |
+
3. When editing existing files, use edit_file (search & replace) instead of rewriting.
|
| 52 |
+
4. Run commands to verify your code works (e.g., python file.py, npm test).
|
| 53 |
+
5. Think step-by-step. Plan before coding complex features.
|
| 54 |
+
6. Use the todo tool to track multi-step tasks.
|
| 55 |
+
7. If a tool fails, read the error and try to fix it.
|
| 56 |
+
8. When done, summarize what you built.
|
| 57 |
+
|
| 58 |
+
## File Output Format
|
| 59 |
+
You can write code in two ways:
|
| 60 |
+
1. Use the write_file tool for each file (preferred)
|
| 61 |
+
2. Use fenced code blocks with language tags for display
|
| 62 |
+
|
| 63 |
+
## Tool Call Format
|
| 64 |
+
To use a tool, output a fenced code block with "tool" language:
|
| 65 |
+
```tool
|
| 66 |
+
tool_name
|
| 67 |
+
key: value
|
| 68 |
+
key2: value2
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Example:
|
| 72 |
+
```tool
|
| 73 |
+
write_file
|
| 74 |
+
path: hello.py
|
| 75 |
+
content: print("Hello, World!")
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
## Multi-File Projects
|
| 79 |
+
For projects with multiple files, write each file separately using write_file.
|
| 80 |
+
Start with the main entry point, then supporting files.
|
| 81 |
+
|
| 82 |
+
## Best Practices
|
| 83 |
+
- Include error handling in your code
|
| 84 |
+
- Add comments for complex logic
|
| 85 |
+
- Use meaningful variable names
|
| 86 |
+
- Follow the language's conventions and idioms
|
| 87 |
+
- Test your code after writing it
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ββ Simple YAML Parser (no PyYAML dependency) ββββββββββββββββββββββββββ
|
| 92 |
+
|
| 93 |
+
def _parse_yaml_block(text: str) -> dict[str, Any]:
|
| 94 |
+
"""Parse a simple YAML-like block into a dictionary.
|
| 95 |
+
|
| 96 |
+
Supports: key: value, key: | (block scalar), - item (lists)
|
| 97 |
+
"""
|
| 98 |
+
result: dict[str, Any] = {}
|
| 99 |
+
current_key: str | None = None
|
| 100 |
+
current_lines: list[str] = []
|
| 101 |
+
in_block = False
|
| 102 |
+
|
| 103 |
+
for line in text.strip().splitlines():
|
| 104 |
+
# Check for list items
|
| 105 |
+
if line.strip().startswith("- "):
|
| 106 |
+
if in_block and current_key:
|
| 107 |
+
result[current_key] = "\n".join(current_lines).strip()
|
| 108 |
+
current_lines = []
|
| 109 |
+
in_block = False
|
| 110 |
+
item = line.strip()[2:].strip()
|
| 111 |
+
if current_key:
|
| 112 |
+
result.setdefault(current_key, [])
|
| 113 |
+
if isinstance(result[current_key], str):
|
| 114 |
+
result[current_key] = [result[current_key]]
|
| 115 |
+
result[current_key].append(item)
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
# Check for block scalar
|
| 119 |
+
if line.endswith("|"):
|
| 120 |
+
current_key = line[:-1].strip().rstrip(":")
|
| 121 |
+
in_block = True
|
| 122 |
+
current_lines = []
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
# Check for key: value
|
| 126 |
+
match = re.match(r"^(\w[\w\s]*?)\s*:\s*(.*)", line)
|
| 127 |
+
if match and not in_block:
|
| 128 |
+
# Save previous block
|
| 129 |
+
if current_key and in_block and current_lines:
|
| 130 |
+
result[current_key] = "\n".join(current_lines).strip()
|
| 131 |
+
|
| 132 |
+
key = match.group(1).strip()
|
| 133 |
+
value = match.group(2).strip()
|
| 134 |
+
current_key = key
|
| 135 |
+
|
| 136 |
+
# Type coercion
|
| 137 |
+
if value.lower() in ("true", "yes"):
|
| 138 |
+
result[key] = True
|
| 139 |
+
elif value.lower() in ("false", "no"):
|
| 140 |
+
result[key] = False
|
| 141 |
+
elif value.isdigit():
|
| 142 |
+
result[key] = int(value)
|
| 143 |
+
else:
|
| 144 |
+
try:
|
| 145 |
+
result[key] = float(value)
|
| 146 |
+
except ValueError:
|
| 147 |
+
result[key] = value
|
| 148 |
+
in_block = False
|
| 149 |
+
current_lines = []
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
if in_block and current_key:
|
| 153 |
+
current_lines.append(line)
|
| 154 |
+
|
| 155 |
+
# Save remaining block
|
| 156 |
+
if in_block and current_key and current_lines:
|
| 157 |
+
result[current_key] = "\n".join(current_lines).strip()
|
| 158 |
+
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _parse_tool_calls(text: str) -> list[dict[str, Any]]:
|
| 163 |
+
"""Parse all tool calls from model output.
|
| 164 |
+
|
| 165 |
+
Format:
|
| 166 |
+
```tool
|
| 167 |
+
tool_name
|
| 168 |
+
key: value
|
| 169 |
+
```
|
| 170 |
+
"""
|
| 171 |
+
calls = []
|
| 172 |
+
for match in TOOL_CALL_RE.finditer(text):
|
| 173 |
+
block = match.group(1).strip()
|
| 174 |
+
lines = block.split("\n", 1)
|
| 175 |
+
|
| 176 |
+
tool_name = lines[0].strip()
|
| 177 |
+
args_raw = lines[1] if len(lines) > 1 else ""
|
| 178 |
+
args = _parse_yaml_block(args_raw)
|
| 179 |
+
|
| 180 |
+
calls.append({
|
| 181 |
+
"tool_name": tool_name,
|
| 182 |
+
"args": args,
|
| 183 |
+
"raw": block,
|
| 184 |
+
})
|
| 185 |
+
|
| 186 |
+
return calls
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ββ Agent Session ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
|
| 191 |
+
class AgentSession(AgentProtocol):
|
| 192 |
+
"""Full agent session with tool execution loop.
|
| 193 |
+
|
| 194 |
+
This is the main class that runs the agentic loop:
|
| 195 |
+
user message β model β tool calls β execute β feed back β model β ... β done
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
def __init__(
|
| 199 |
+
self,
|
| 200 |
+
settings: Settings | None = None,
|
| 201 |
+
tool_registry: ToolRegistry | None = None,
|
| 202 |
+
):
|
| 203 |
+
super().__init__()
|
| 204 |
+
self._settings = settings or Settings()
|
| 205 |
+
self._registry = tool_registry or get_tool_registry()
|
| 206 |
+
self._policy = get_policy_engine()
|
| 207 |
+
self._mcp = get_mcp_manager()
|
| 208 |
+
self._history: list[dict[str, str]] = []
|
| 209 |
+
self._total_tokens = 0
|
| 210 |
+
self._tool_calls = 0
|
| 211 |
+
self._start_time = 0.0
|
| 212 |
+
|
| 213 |
+
def run(
|
| 214 |
+
self,
|
| 215 |
+
message: str,
|
| 216 |
+
session_id: str = "",
|
| 217 |
+
image_url: str | None = None,
|
| 218 |
+
) -> Generator[dict, None, None]:
|
| 219 |
+
"""Run the agent loop. Yields event dicts for streaming to the client."""
|
| 220 |
+
self._start_time = time.time()
|
| 221 |
+
self._tool_calls = 0
|
| 222 |
+
|
| 223 |
+
# Initialize
|
| 224 |
+
stream_id = self.send(message)
|
| 225 |
+
self._emit(AgentEventType.STATUS, {"status": "starting", "message": "Starting agent..."})
|
| 226 |
+
|
| 227 |
+
# Add user message to history
|
| 228 |
+
self._history.append({"role": "user", "content": message})
|
| 229 |
+
|
| 230 |
+
# Build tool schemas
|
| 231 |
+
tool_schemas = self._build_tool_schemas()
|
| 232 |
+
|
| 233 |
+
# Build full system prompt
|
| 234 |
+
system_prompt = self._build_system_prompt(tool_schemas)
|
| 235 |
+
|
| 236 |
+
# Agent loop
|
| 237 |
+
max_iterations = self._settings.max_iterations
|
| 238 |
+
model_config = get_model_config(self._settings.default_model)
|
| 239 |
+
|
| 240 |
+
for iteration in range(max_iterations):
|
| 241 |
+
if self.is_aborted:
|
| 242 |
+
self._emit(AgentEventType.COMPLETE, {"reason": "aborted"})
|
| 243 |
+
yield {"type": "complete", "reason": "aborted"}
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
self._emit(AgentEventType.STATUS, {
|
| 247 |
+
"status": "thinking",
|
| 248 |
+
"iteration": iteration + 1,
|
| 249 |
+
"max_iterations": max_iterations,
|
| 250 |
+
"message": f"Thinking (iteration {iteration + 1}/{max_iterations})...",
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
# Call model
|
| 254 |
+
try:
|
| 255 |
+
response_text = ""
|
| 256 |
+
for chunk in call_model_stream(
|
| 257 |
+
messages=self._history,
|
| 258 |
+
system_prompt=system_prompt,
|
| 259 |
+
max_new_tokens=self._settings.max_tokens,
|
| 260 |
+
temperature=self._settings.temperature,
|
| 261 |
+
image_url=image_url,
|
| 262 |
+
):
|
| 263 |
+
response_text += chunk
|
| 264 |
+
# Strip thinking blocks for display
|
| 265 |
+
display = THINKING_BLOCK_RE.sub("", response_text)
|
| 266 |
+
# Strip tool calls for display
|
| 267 |
+
display_clean = TOOL_CALL_RE.sub("", display).strip()
|
| 268 |
+
yield {"type": "streaming", "content": display_clean, "full_text": response_text}
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
self._emit(AgentEventType.ERROR, {"error": str(e)})
|
| 272 |
+
yield {"type": "error", "error": str(e)}
|
| 273 |
+
return
|
| 274 |
+
|
| 275 |
+
# Add assistant response to history
|
| 276 |
+
self._history.append({"role": "assistant", "content": response_text})
|
| 277 |
+
|
| 278 |
+
# Parse tool calls
|
| 279 |
+
tool_calls = _parse_tool_calls(response_text)
|
| 280 |
+
|
| 281 |
+
if not tool_calls:
|
| 282 |
+
# No tool calls β agent is done
|
| 283 |
+
display = THINKING_BLOCK_RE.sub("", response_text).strip()
|
| 284 |
+
self._emit(AgentEventType.MESSAGE, {"content": display})
|
| 285 |
+
self._emit(AgentEventType.COMPLETE, {"reason": "done"})
|
| 286 |
+
elapsed = time.time() - self._start_time
|
| 287 |
+
yield {
|
| 288 |
+
"type": "complete",
|
| 289 |
+
"reason": "done",
|
| 290 |
+
"content": display,
|
| 291 |
+
"iterations": iteration + 1,
|
| 292 |
+
"tool_calls": self._tool_calls,
|
| 293 |
+
"elapsed_seconds": round(elapsed, 1),
|
| 294 |
+
}
|
| 295 |
+
return
|
| 296 |
+
|
| 297 |
+
# Process tool calls
|
| 298 |
+
for tc in tool_calls:
|
| 299 |
+
if self.is_aborted:
|
| 300 |
+
yield {"type": "complete", "reason": "aborted"}
|
| 301 |
+
return
|
| 302 |
+
|
| 303 |
+
tool_name = tc["tool_name"]
|
| 304 |
+
args = tc["args"]
|
| 305 |
+
|
| 306 |
+
self._emit(AgentEventType.TOOL_REQUEST, {
|
| 307 |
+
"tool": tool_name,
|
| 308 |
+
"args": args,
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
yield {
|
| 312 |
+
"type": "tool_call",
|
| 313 |
+
"tool": tool_name,
|
| 314 |
+
"args": args,
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
# Check policy
|
| 318 |
+
policy_result = self._policy.check(tool_name, args)
|
| 319 |
+
if policy_result.decision == ApprovalDecision.DENY:
|
| 320 |
+
result = ToolResult(
|
| 321 |
+
success=False,
|
| 322 |
+
error=f"Blocked by policy: {policy_result.reason}",
|
| 323 |
+
)
|
| 324 |
+
else:
|
| 325 |
+
# Execute tool
|
| 326 |
+
result = self._execute_tool(tool_name, args, session_id)
|
| 327 |
+
|
| 328 |
+
self._tool_calls += 1
|
| 329 |
+
self._emit(AgentEventType.TOOL_RESPONSE, {
|
| 330 |
+
"tool": tool_name,
|
| 331 |
+
"result": result.to_dict(),
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
yield {
|
| 335 |
+
"type": "tool_result",
|
| 336 |
+
"tool": tool_name,
|
| 337 |
+
"result": result.to_dict(),
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
# Feed result back to model
|
| 341 |
+
result_content = result.to_json()
|
| 342 |
+
self._history.append({
|
| 343 |
+
"role": "tool",
|
| 344 |
+
"content": f"[{tool_name}] {result_content}",
|
| 345 |
+
})
|
| 346 |
+
|
| 347 |
+
# Max iterations reached
|
| 348 |
+
self._emit(AgentEventType.COMPLETE, {"reason": "max_iterations"})
|
| 349 |
+
yield {
|
| 350 |
+
"type": "complete",
|
| 351 |
+
"reason": "max_iterations",
|
| 352 |
+
"iterations": max_iterations,
|
| 353 |
+
"tool_calls": self._tool_calls,
|
| 354 |
+
"elapsed_seconds": round(time.time() - self._start_time, 1),
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
def _execute_tool(
|
| 358 |
+
self, tool_name: str, args: dict[str, Any], session_id: str
|
| 359 |
+
) -> ToolResult:
|
| 360 |
+
"""Execute a single tool call."""
|
| 361 |
+
# Check if it's an MCP tool
|
| 362 |
+
if tool_name.startswith("mcp_"):
|
| 363 |
+
return self._execute_mcp_tool(tool_name, args)
|
| 364 |
+
|
| 365 |
+
# Look up in registry
|
| 366 |
+
tool_builder = self._registry.get_tool(tool_name)
|
| 367 |
+
if not tool_builder:
|
| 368 |
+
return ToolResult(success=False, error=f"Unknown tool: {tool_name}")
|
| 369 |
+
|
| 370 |
+
# Validate
|
| 371 |
+
errors = tool_builder.validate(args)
|
| 372 |
+
if errors:
|
| 373 |
+
return ToolResult(success=False, error=f"Validation error: {'; '.join(errors)}")
|
| 374 |
+
|
| 375 |
+
# Create invocation
|
| 376 |
+
invocation = tool_builder.create_invocation(args)
|
| 377 |
+
|
| 378 |
+
# Inject session_id for todo tool
|
| 379 |
+
if tool_name == "todo":
|
| 380 |
+
invocation.session_id = session_id
|
| 381 |
+
|
| 382 |
+
# Execute
|
| 383 |
+
try:
|
| 384 |
+
return invocation.execute()
|
| 385 |
+
except Exception as e:
|
| 386 |
+
logger.error(f"Tool execution error ({tool_name}): {e}")
|
| 387 |
+
return ToolResult(success=False, error=f"Tool error: {e}")
|
| 388 |
+
|
| 389 |
+
def _execute_mcp_tool(self, tool_name: str, args: dict[str, Any]) -> ToolResult:
|
| 390 |
+
"""Execute an MCP tool."""
|
| 391 |
+
bridge = self._mcp.get_tool(tool_name)
|
| 392 |
+
if not bridge:
|
| 393 |
+
return ToolResult(success=False, error=f"MCP tool not found: {tool_name}")
|
| 394 |
+
|
| 395 |
+
try:
|
| 396 |
+
result = self._mcp.call_tool(bridge.server_name, bridge.tool_name, args)
|
| 397 |
+
if "error" in result:
|
| 398 |
+
return ToolResult(success=False, error=result["error"])
|
| 399 |
+
return ToolResult(success=True, output=result.get("output", str(result)))
|
| 400 |
+
except Exception as e:
|
| 401 |
+
return ToolResult(success=False, error=f"MCP error: {e}")
|
| 402 |
+
|
| 403 |
+
def _build_tool_schemas(self) -> str:
|
| 404 |
+
"""Build tool schema documentation for the system prompt."""
|
| 405 |
+
active = self._registry.get_active_tools()
|
| 406 |
+
mcp_tools = self._mcp.get_tools()
|
| 407 |
+
|
| 408 |
+
schema_parts = []
|
| 409 |
+
for name, tool in active.items():
|
| 410 |
+
schema_parts.append(f"### {name}\n{tool.description}")
|
| 411 |
+
if hasattr(tool, "get_schema"):
|
| 412 |
+
params = tool.get_schema()
|
| 413 |
+
props = params.get("properties", {})
|
| 414 |
+
required = params.get("required", [])
|
| 415 |
+
if props:
|
| 416 |
+
schema_parts.append("Parameters:")
|
| 417 |
+
for pname, pinfo in props.items():
|
| 418 |
+
req = " (required)" if pname in required else ""
|
| 419 |
+
ptype = pinfo.get("type", "any")
|
| 420 |
+
desc = pinfo.get("description", "")
|
| 421 |
+
schema_parts.append(f" - {pname}: {ptype}{req} β {desc}")
|
| 422 |
+
schema_parts.append("")
|
| 423 |
+
|
| 424 |
+
# Add MCP tools
|
| 425 |
+
for name, bridge in mcp_tools.items():
|
| 426 |
+
schema_parts.append(f"### {name}\n{bridge.description}")
|
| 427 |
+
params = bridge._schema.get("inputSchema", {}).get("properties", {})
|
| 428 |
+
for pname, pinfo in params.items():
|
| 429 |
+
desc = pinfo.get("description", "")
|
| 430 |
+
schema_parts.append(f" - {pname}: {desc}")
|
| 431 |
+
schema_parts.append("")
|
| 432 |
+
|
| 433 |
+
if not schema_parts:
|
| 434 |
+
return "No tools available."
|
| 435 |
+
|
| 436 |
+
return "## Available Tools\n\n" + "\n".join(schema_parts)
|
| 437 |
+
|
| 438 |
+
def _build_system_prompt(self, tool_schemas: str) -> str:
|
| 439 |
+
"""Build the full system prompt."""
|
| 440 |
+
parts = [SYSTEM_PROMPT, "\n", tool_schemas]
|
| 441 |
+
|
| 442 |
+
# Add model-specific tips
|
| 443 |
+
model_key = self._settings.default_model
|
| 444 |
+
if "qwen" in model_key.lower():
|
| 445 |
+
parts.append(
|
| 446 |
+
"\n## Tips for Qwen Models\n"
|
| 447 |
+
"- Be explicit and structured in your responses\n"
|
| 448 |
+
"- Use complete file paths in tool calls\n"
|
| 449 |
+
"- Break complex tasks into clear steps"
|
| 450 |
+
)
|
| 451 |
+
elif "deepseek" in model_key.lower():
|
| 452 |
+
parts.append(
|
| 453 |
+
"\n## Tips for DeepSeek Models\n"
|
| 454 |
+
"- Think carefully before acting\n"
|
| 455 |
+
"- Use comments to explain complex code\n"
|
| 456 |
+
"- Verify code correctness with test commands"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
return "\n".join(parts)
|
| 460 |
+
|
| 461 |
+
def clear_history(self) -> None:
|
| 462 |
+
"""Clear conversation history."""
|
| 463 |
+
self._history.clear()
|
| 464 |
+
self._total_tokens = 0
|
| 465 |
+
self._tool_calls = 0
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
# ββ Register Built-in Tools ββββββββββββββββββββββββββββββββββββββββββββ
|
| 469 |
+
|
| 470 |
+
def register_builtin_tools(registry: ToolRegistry) -> None:
|
| 471 |
+
"""Register all built-in tools into the registry."""
|
| 472 |
+
for tools_module in [FS_TOOLS, BASH_TOOLS, WEB_TOOLS, TODO_TOOLS]:
|
| 473 |
+
for tool in tools_module:
|
| 474 |
+
registry.register(tool)
|
sonicoder/agent/protocol.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent protocol β event-driven architecture from Gemini CLI.
|
| 2 |
+
|
| 3 |
+
The agent is purely event-driven. All communication flows through typed events.
|
| 4 |
+
This is the core architectural pattern borrowed from Gemini CLI's AgentProtocol.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from enum import Enum
|
| 13 |
+
from typing import Any, Callable
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ββ Event Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
|
| 18 |
+
class AgentEventType(Enum):
|
| 19 |
+
"""All possible agent events β discriminated union pattern from Gemini CLI."""
|
| 20 |
+
INITIALIZE = "initialize"
|
| 21 |
+
STATUS = "status"
|
| 22 |
+
MESSAGE = "message" # Agent reasoning/text output
|
| 23 |
+
TOOL_REQUEST = "tool_request" # Agent wants to call a tool
|
| 24 |
+
TOOL_RESPONSE = "tool_response" # Tool result returned
|
| 25 |
+
STREAMING = "streaming" # Incremental token
|
| 26 |
+
COMPLETE = "complete" # Agent finished (success)
|
| 27 |
+
ERROR = "error" # Agent finished (error)
|
| 28 |
+
USAGE = "usage" # Token usage stats
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class AgentEvent:
|
| 33 |
+
"""A single event in the agent event stream."""
|
| 34 |
+
type: AgentEventType
|
| 35 |
+
data: dict[str, Any] = field(default_factory=dict)
|
| 36 |
+
timestamp: float = field(default_factory=time.time)
|
| 37 |
+
event_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
| 38 |
+
|
| 39 |
+
def to_dict(self) -> dict[str, Any]:
|
| 40 |
+
return {
|
| 41 |
+
"type": self.type.value,
|
| 42 |
+
"data": self.data,
|
| 43 |
+
"timestamp": self.timestamp,
|
| 44 |
+
"event_id": self.event_id,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ββ Agent Protocol Interface βββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
class AgentProtocol:
|
| 51 |
+
"""Event-driven agent interface.
|
| 52 |
+
|
| 53 |
+
Inspired by Gemini CLI's AgentProtocol:
|
| 54 |
+
- send(): Submit a user message
|
| 55 |
+
- subscribe(): Listen for events
|
| 56 |
+
- abort(): Cancel current operation
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
def __init__(self):
|
| 60 |
+
self._subscribers: list[Callable[[AgentEvent], None]] = []
|
| 61 |
+
self._events: list[AgentEvent] = []
|
| 62 |
+
self._aborted = False
|
| 63 |
+
self._stream_id: str | None = None
|
| 64 |
+
|
| 65 |
+
def send(self, message: str, **kwargs) -> str:
|
| 66 |
+
"""Submit a user message. Returns stream_id."""
|
| 67 |
+
self._aborted = False
|
| 68 |
+
self._stream_id = str(uuid.uuid4())[:12]
|
| 69 |
+
self._emit(AgentEventType.INITIALIZE, {
|
| 70 |
+
"stream_id": self._stream_id,
|
| 71 |
+
"message": message,
|
| 72 |
+
})
|
| 73 |
+
return self._stream_id
|
| 74 |
+
|
| 75 |
+
def subscribe(self, callback: Callable[[AgentEvent], None]) -> Callable:
|
| 76 |
+
"""Subscribe to events. Returns unsubscribe function."""
|
| 77 |
+
self._subscribers.append(callback)
|
| 78 |
+
def unsubscribe():
|
| 79 |
+
self._subscribers.remove(callback)
|
| 80 |
+
return unsubscribe
|
| 81 |
+
|
| 82 |
+
def abort(self) -> None:
|
| 83 |
+
"""Cancel the current operation."""
|
| 84 |
+
self._aborted = True
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def is_aborted(self) -> bool:
|
| 88 |
+
return self._aborted
|
| 89 |
+
|
| 90 |
+
def _emit(self, event_type: AgentEventType, data: dict[str, Any] | None = None) -> AgentEvent:
|
| 91 |
+
"""Emit an event to all subscribers."""
|
| 92 |
+
event = AgentEvent(type=event_type, data=data or {})
|
| 93 |
+
self._events.append(event)
|
| 94 |
+
for sub in self._subscribers:
|
| 95 |
+
try:
|
| 96 |
+
sub(event)
|
| 97 |
+
except Exception:
|
| 98 |
+
pass
|
| 99 |
+
return event
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def events(self) -> list[AgentEvent]:
|
| 103 |
+
return list(self._events)
|
| 104 |
+
|
| 105 |
+
def clear_history(self) -> None:
|
| 106 |
+
self._events.clear()
|
sonicoder/config/__init__.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration system β hierarchical settings like Claude Code."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
# ββ Defaults ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12 |
+
|
| 13 |
+
WORKSPACE_ROOT = Path(os.environ.get("SONICODER_WORKSPACE", "./workspace"))
|
| 14 |
+
CONFIG_DIR = Path(os.environ.get("SONICODER_CONFIG", ".sonicoder"))
|
| 15 |
+
|
| 16 |
+
DEFAULT_TEMPERATURE = 0.35
|
| 17 |
+
DEFAULT_MAX_TOKENS = 4096
|
| 18 |
+
DEFAULT_MAX_ITERATIONS = 12
|
| 19 |
+
AGENT_LOOP_TIMEOUT = 300 # seconds
|
| 20 |
+
BASH_TIMEOUT = 120
|
| 21 |
+
PYTHON_TIMEOUT = 30
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ModelConfig:
|
| 26 |
+
"""Configuration for a single model."""
|
| 27 |
+
name: str
|
| 28 |
+
model_id: str
|
| 29 |
+
model_type: str # "text" | "vlm"
|
| 30 |
+
size_gb: float
|
| 31 |
+
description: str
|
| 32 |
+
dtype: str = "float16"
|
| 33 |
+
trust_remote_code: bool = True
|
| 34 |
+
device: str = "auto"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class MCPServerConfig:
|
| 39 |
+
"""Configuration for an MCP server."""
|
| 40 |
+
name: str
|
| 41 |
+
transport: str # "stdio" | "sse"
|
| 42 |
+
command: str | None = None
|
| 43 |
+
args: list[str] = field(default_factory=list)
|
| 44 |
+
env: dict[str, str] = field(default_factory=dict)
|
| 45 |
+
url: str | None = None
|
| 46 |
+
enabled: bool = True
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class PolicyRule:
|
| 51 |
+
"""A policy rule for tool approval."""
|
| 52 |
+
tool_name: str # supports "*" wildcard
|
| 53 |
+
args_pattern: str | None = None
|
| 54 |
+
approval: str = "ask" # "allow" | "ask" | "deny"
|
| 55 |
+
priority: int = 0
|
| 56 |
+
description: str = ""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class Settings:
|
| 61 |
+
"""Hierarchical settings β cascades from global β project β local."""
|
| 62 |
+
# Model
|
| 63 |
+
default_model: str = "qwen25-coder-1.5b"
|
| 64 |
+
temperature: float = DEFAULT_TEMPERATURE
|
| 65 |
+
max_tokens: int = DEFAULT_MAX_TOKENS
|
| 66 |
+
|
| 67 |
+
# Agent
|
| 68 |
+
max_iterations: int = DEFAULT_MAX_ITERATIONS
|
| 69 |
+
agent_timeout: int = AGENT_LOOP_TIMEOUT
|
| 70 |
+
|
| 71 |
+
# Tools
|
| 72 |
+
enabled_tools: list[str] = field(default_factory=lambda: ["*"])
|
| 73 |
+
disabled_tools: list[str] = field(default_factory=list)
|
| 74 |
+
|
| 75 |
+
# Safety
|
| 76 |
+
sandbox_enabled: bool = True
|
| 77 |
+
bash_timeout: int = BASH_TIMEOUT
|
| 78 |
+
python_timeout: int = PYTHON_TIMEOUT
|
| 79 |
+
|
| 80 |
+
# MCP
|
| 81 |
+
mcp_servers: list[MCPServerConfig] = field(default_factory=list)
|
| 82 |
+
|
| 83 |
+
# Policy
|
| 84 |
+
policy_rules: list[PolicyRule] = field(default_factory=list)
|
| 85 |
+
|
| 86 |
+
# Context
|
| 87 |
+
max_context_tokens: int = 32768
|
| 88 |
+
auto_compact: bool = True
|
| 89 |
+
|
| 90 |
+
@classmethod
|
| 91 |
+
def from_dict(cls, data: dict[str, Any]) -> Settings:
|
| 92 |
+
"""Create settings from a dictionary, ignoring unknown fields."""
|
| 93 |
+
known_fields = {f.name for f in cls.__dataclass_fields__.values()}
|
| 94 |
+
filtered = {k: v for k, v in data.items() if k in known_fields}
|
| 95 |
+
return cls(**filtered)
|
| 96 |
+
|
| 97 |
+
def merge(self, other: Settings) -> Settings:
|
| 98 |
+
"""Merge another settings object (other takes precedence for non-default values)."""
|
| 99 |
+
result = Settings(
|
| 100 |
+
default_model=other.default_model or self.default_model,
|
| 101 |
+
temperature=other.temperature if other.temperature != DEFAULT_TEMPERATURE else self.temperature,
|
| 102 |
+
max_tokens=other.max_tokens if other.max_tokens != DEFAULT_MAX_TOKENS else self.max_tokens,
|
| 103 |
+
max_iterations=other.max_iterations if other.max_iterations != DEFAULT_MAX_ITERATIONS else self.max_iterations,
|
| 104 |
+
agent_timeout=other.agent_timeout if other.agent_timeout != AGENT_LOOP_TIMEOUT else self.agent_timeout,
|
| 105 |
+
sandbox_enabled=other.sandbox_enabled,
|
| 106 |
+
bash_timeout=other.bash_timeout,
|
| 107 |
+
python_timeout=other.python_timeout,
|
| 108 |
+
auto_compact=other.auto_compact,
|
| 109 |
+
)
|
| 110 |
+
# Merge lists
|
| 111 |
+
result.enabled_tools = other.enabled_tools if other.enabled_tools != ["*"] else self.enabled_tools
|
| 112 |
+
result.disabled_tools = list(set(self.disabled_tools + other.disabled_tools))
|
| 113 |
+
result.mcp_servers = self.mcp_servers + other.mcp_servers
|
| 114 |
+
result.policy_rules = other.policy_rules + self.policy_rules
|
| 115 |
+
return result
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ββ Model Registry ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
|
| 120 |
+
MODEL_REGISTRY: dict[str, ModelConfig] = {
|
| 121 |
+
"qwen25-coder-1.5b": ModelConfig(
|
| 122 |
+
name="Qwen2.5-Coder-1.5B",
|
| 123 |
+
model_id="Qwen/Qwen2.5-Coder-1.5B-Instruct",
|
| 124 |
+
model_type="text",
|
| 125 |
+
size_gb=2.9,
|
| 126 |
+
description="Best-in-class 1.5B code model. Excellent at Python, JS, TypeScript, and multi-file generation.",
|
| 127 |
+
),
|
| 128 |
+
"deepseek-coder-1.3b": ModelConfig(
|
| 129 |
+
name="DeepSeek-Coder-1.3B",
|
| 130 |
+
model_id="deepseek-ai/deepseek-coder-1.3b-instruct",
|
| 131 |
+
model_type="text",
|
| 132 |
+
size_gb=2.6,
|
| 133 |
+
description="Strong code understanding and generation. Good at multi-language coding tasks.",
|
| 134 |
+
),
|
| 135 |
+
"qwen25-1.5b": ModelConfig(
|
| 136 |
+
name="Qwen2.5-1.5B-Instruct",
|
| 137 |
+
model_id="Qwen/Qwen2.5-1.5B-Instruct",
|
| 138 |
+
model_type="text",
|
| 139 |
+
size_gb=2.9,
|
| 140 |
+
description="General-purpose 1.5B model. Good for reasoning, analysis, and non-code tasks.",
|
| 141 |
+
),
|
| 142 |
+
"smolvlm-2.2b": ModelConfig(
|
| 143 |
+
name="SmolVLM-2.2B",
|
| 144 |
+
model_id="HuggingFaceTB/SmolVLM2-2.2B-Instruct",
|
| 145 |
+
model_type="vlm",
|
| 146 |
+
size_gb=4.4,
|
| 147 |
+
description="Vision-language model. Can understand screenshots, diagrams, and generate code from images.",
|
| 148 |
+
),
|
| 149 |
+
"minicpm5-1b": ModelConfig(
|
| 150 |
+
name="MiniCPM5-1B",
|
| 151 |
+
model_id="openbmb/MiniCPM5-1B",
|
| 152 |
+
model_type="text",
|
| 153 |
+
size_gb=2.17,
|
| 154 |
+
description="Compact text model. Fast inference, decent code generation.",
|
| 155 |
+
),
|
| 156 |
+
"tinyllama-1.1b": ModelConfig(
|
| 157 |
+
name="TinyLlama-1.1B-Chat",
|
| 158 |
+
model_id="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
|
| 159 |
+
model_type="text",
|
| 160 |
+
size_gb=2.2,
|
| 161 |
+
description="Ultra-compact chat model. Good for simple tasks, fastest inference.",
|
| 162 |
+
),
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def get_available_models() -> dict[str, ModelConfig]:
|
| 167 |
+
"""Return all registered models."""
|
| 168 |
+
return MODEL_REGISTRY.copy()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def get_model_config(model_key: str) -> ModelConfig | None:
|
| 172 |
+
"""Get a model config by key."""
|
| 173 |
+
return MODEL_REGISTRY.get(model_key)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def load_settings(project_dir: Path | None = None) -> Settings:
|
| 177 |
+
"""Load settings with hierarchical cascading.
|
| 178 |
+
|
| 179 |
+
Priority (highest β lowest):
|
| 180 |
+
1. Environment variables
|
| 181 |
+
2. .sonicoder/settings.local.json (local overrides, gitignored)
|
| 182 |
+
3. .sonicoder/settings.json (project settings)
|
| 183 |
+
4. Defaults
|
| 184 |
+
"""
|
| 185 |
+
settings = Settings()
|
| 186 |
+
|
| 187 |
+
search_dirs = [Path.cwd()]
|
| 188 |
+
if project_dir:
|
| 189 |
+
search_dirs.insert(0, project_dir)
|
| 190 |
+
|
| 191 |
+
# Load project-level settings
|
| 192 |
+
for d in search_dirs:
|
| 193 |
+
local_path = d / CONFIG_DIR / "settings.local.json"
|
| 194 |
+
proj_path = d / CONFIG_DIR / "settings.json"
|
| 195 |
+
|
| 196 |
+
for path in [local_path, proj_path]:
|
| 197 |
+
if path.exists():
|
| 198 |
+
try:
|
| 199 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 200 |
+
# Parse MCP server configs
|
| 201 |
+
if "mcp_servers" in data:
|
| 202 |
+
data["mcp_servers"] = [
|
| 203 |
+
MCPServerConfig(**s) for s in data["mcp_servers"]
|
| 204 |
+
]
|
| 205 |
+
if "policy_rules" in data:
|
| 206 |
+
data["policy_rules"] = [
|
| 207 |
+
PolicyRule(**r) for r in data["policy_rules"]
|
| 208 |
+
]
|
| 209 |
+
settings = settings.merge(Settings.from_dict(data))
|
| 210 |
+
except (json.JSONDecodeError, TypeError) as e:
|
| 211 |
+
print(f"[config] Warning: Failed to parse {path}: {e}")
|
| 212 |
+
|
| 213 |
+
# Environment variable overrides
|
| 214 |
+
env_model = os.environ.get("SONICODER_MODEL")
|
| 215 |
+
if env_model and env_model in MODEL_REGISTRY:
|
| 216 |
+
settings.default_model = env_model
|
| 217 |
+
|
| 218 |
+
env_temp = os.environ.get("SONICODER_TEMPERATURE")
|
| 219 |
+
if env_temp:
|
| 220 |
+
try:
|
| 221 |
+
settings.temperature = float(env_temp)
|
| 222 |
+
except ValueError:
|
| 223 |
+
pass
|
| 224 |
+
|
| 225 |
+
return settings
|
sonicoder/context/__init__.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Context window manager β conversation history with compression.
|
| 2 |
+
|
| 3 |
+
Inspired by Gemini CLI's graph-based context pipeline and Claude Code's auto-compaction.
|
| 4 |
+
For 1B models, context window is small (~32K tokens), so compression is critical.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class ContextNode:
|
| 18 |
+
"""A single node in the context graph."""
|
| 19 |
+
role: str # "user" | "assistant" | "tool" | "system"
|
| 20 |
+
content: str
|
| 21 |
+
tokens: int = 0
|
| 22 |
+
compressed: bool = False
|
| 23 |
+
protected: bool = False # Can't be GC'd
|
| 24 |
+
turn_index: int = 0
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ContextManager:
|
| 28 |
+
"""Manages conversation context within a token budget.
|
| 29 |
+
|
| 30 |
+
Strategies (inspired by both Gemini CLI and Claude Code):
|
| 31 |
+
1. Clear old tool outputs first (biggest token consumers)
|
| 32 |
+
2. Compress old conversation turns
|
| 33 |
+
3. Protect recent turns and system messages
|
| 34 |
+
4. Lazy tool schema loading (deferred definitions like Claude Code)
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, max_tokens: int = 32768, auto_compact: bool = True):
|
| 38 |
+
self.max_tokens = max_tokens
|
| 39 |
+
self.auto_compact = auto_compact
|
| 40 |
+
self._nodes: list[ContextNode] = []
|
| 41 |
+
self._turn_count = 0
|
| 42 |
+
|
| 43 |
+
def add_message(self, role: str, content: str, protected: bool = False) -> None:
|
| 44 |
+
"""Add a message to the context."""
|
| 45 |
+
tokens = self._estimate_tokens(content)
|
| 46 |
+
node = ContextNode(
|
| 47 |
+
role=role,
|
| 48 |
+
content=content,
|
| 49 |
+
tokens=tokens,
|
| 50 |
+
protected=protected,
|
| 51 |
+
turn_index=self._turn_count,
|
| 52 |
+
)
|
| 53 |
+
self._nodes.append(node)
|
| 54 |
+
if role == "user":
|
| 55 |
+
self._turn_count += 1
|
| 56 |
+
|
| 57 |
+
def get_messages(self) -> list[dict[str, str]]:
|
| 58 |
+
"""Get all messages for the LLM, with auto-compaction if needed."""
|
| 59 |
+
if self.auto_compact:
|
| 60 |
+
self._maybe_compact()
|
| 61 |
+
|
| 62 |
+
return [
|
| 63 |
+
{"role": n.role, "content": n.content}
|
| 64 |
+
for n in self._nodes
|
| 65 |
+
if n.content # Skip empty nodes
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
def total_tokens(self) -> int:
|
| 69 |
+
"""Estimate total tokens in context."""
|
| 70 |
+
return sum(n.tokens for n in self._nodes)
|
| 71 |
+
|
| 72 |
+
def clear(self) -> None:
|
| 73 |
+
"""Clear all context."""
|
| 74 |
+
self._nodes.clear()
|
| 75 |
+
self._turn_count = 0
|
| 76 |
+
|
| 77 |
+
def _maybe_compact(self) -> None:
|
| 78 |
+
"""Auto-compact if over budget."""
|
| 79 |
+
total = self.total_tokens()
|
| 80 |
+
if total <= self.max_tokens:
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
logger.info(f"Context over budget ({total} > {self.max_tokens}). Compacting...")
|
| 84 |
+
|
| 85 |
+
# Phase 1: Remove old tool results (biggest savings)
|
| 86 |
+
budget_remaining = self.max_tokens * 0.8 # Target 80% utilization
|
| 87 |
+
for node in self._nodes:
|
| 88 |
+
if self.total_tokens() <= budget_remaining:
|
| 89 |
+
break
|
| 90 |
+
if node.role == "tool" and not node.protected:
|
| 91 |
+
# Compress tool output
|
| 92 |
+
if len(node.content) > 200:
|
| 93 |
+
node.content = node.content[:200] + f"\n... [truncated, was {len(node.content)} chars]"
|
| 94 |
+
node.tokens = self._estimate_tokens(node.content)
|
| 95 |
+
node.compressed = True
|
| 96 |
+
|
| 97 |
+
# Phase 2: Summarize old conversation turns
|
| 98 |
+
if self.total_tokens() > self.max_tokens:
|
| 99 |
+
# Find oldest non-protected assistant/user pairs to compress
|
| 100 |
+
pairs = self._find_compactable_pairs()
|
| 101 |
+
for user_node, assistant_node in pairs:
|
| 102 |
+
if self.total_tokens() <= budget_remaining:
|
| 103 |
+
break
|
| 104 |
+
# Replace with summary
|
| 105 |
+
summary = self._summarize_pair(user_node, assistant_node)
|
| 106 |
+
user_node.content = summary
|
| 107 |
+
user_node.tokens = self._estimate_tokens(summary)
|
| 108 |
+
user_node.compressed = True
|
| 109 |
+
# Remove assistant node
|
| 110 |
+
self._nodes.remove(assistant_node)
|
| 111 |
+
|
| 112 |
+
# Phase 3: If still over budget, remove oldest non-protected nodes
|
| 113 |
+
if self.total_tokens() > self.max_tokens:
|
| 114 |
+
to_remove = []
|
| 115 |
+
for node in self._nodes:
|
| 116 |
+
if not node.protected and not node.compressed:
|
| 117 |
+
to_remove.append(node)
|
| 118 |
+
if self.total_tokens() - sum(n.tokens for n in to_remove) <= budget_remaining:
|
| 119 |
+
break
|
| 120 |
+
for node in to_remove:
|
| 121 |
+
self._nodes.remove(node)
|
| 122 |
+
|
| 123 |
+
logger.info(f"Compaction done. Context now ~{self.total_tokens()} tokens")
|
| 124 |
+
|
| 125 |
+
def _find_compactable_pairs(self) -> list[tuple[ContextNode, ContextNode]]:
|
| 126 |
+
"""Find user+assistant pairs that can be compressed."""
|
| 127 |
+
pairs = []
|
| 128 |
+
i = 0
|
| 129 |
+
while i < len(self._nodes) - 1:
|
| 130 |
+
if (self._nodes[i].role == "user" and
|
| 131 |
+
self._nodes[i + 1].role == "assistant" and
|
| 132 |
+
not self._nodes[i].protected):
|
| 133 |
+
pairs.append((self._nodes[i], self._nodes[i + 1]))
|
| 134 |
+
i += 2
|
| 135 |
+
else:
|
| 136 |
+
i += 1
|
| 137 |
+
return pairs
|
| 138 |
+
|
| 139 |
+
def _summarize_pair(self, user: ContextNode, assistant: ContextNode) -> str:
|
| 140 |
+
"""Create a brief summary of a conversation pair."""
|
| 141 |
+
u_preview = user.content[:100].replace("\n", " ")
|
| 142 |
+
a_preview = assistant.content[:150].replace("\n", " ")
|
| 143 |
+
return f"[Earlier: User asked about '{u_preview}...' and assistant responded with '{a_preview}...']"
|
| 144 |
+
|
| 145 |
+
@staticmethod
|
| 146 |
+
def _estimate_tokens(text: str) -> int:
|
| 147 |
+
"""Rough token estimation (1 token ~ 4 chars for code, ~3.5 for English)."""
|
| 148 |
+
if not text:
|
| 149 |
+
return 0
|
| 150 |
+
# Detect if mostly code
|
| 151 |
+
code_chars = sum(1 for c in text if c in " \t{}[]();:=<>+-*/!&|\\")
|
| 152 |
+
ratio = code_chars / max(len(text), 1)
|
| 153 |
+
chars_per_token = 3.5 + ratio * 1.5 # 3.5 for English, 5 for code
|
| 154 |
+
return int(len(text) / chars_per_token)
|
sonicoder/execution/__init__.py
ADDED
|
File without changes
|
sonicoder/mcp/__init__.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP (Model Context Protocol) support β full client implementation.
|
| 2 |
+
|
| 3 |
+
Architecture from Gemini CLI's McpClientManager:
|
| 4 |
+
- Manages lifecycle of multiple MCP servers
|
| 5 |
+
- Bridges MCP tools into the ToolRegistry as ToolBuilder instances
|
| 6 |
+
- Supports stdio and SSE transports
|
| 7 |
+
- Lazy initialization (connects on first use)
|
| 8 |
+
|
| 9 |
+
Based on the MCP Python SDK.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import threading
|
| 18 |
+
from typing import Any, Callable
|
| 19 |
+
|
| 20 |
+
from ..config import MCPServerConfig
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ββ MCP Tool Bridge ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
class MCPToolBridge:
|
| 28 |
+
"""Wraps an MCP tool as a ToolBuilder, making it indistinguishable from built-in tools.
|
| 29 |
+
|
| 30 |
+
This is the key insight from Gemini CLI: MCP tools use the same ToolBuilder
|
| 31 |
+
interface so they get the same policy, confirmation, and scheduling treatment.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self, server_name: str, tool_name: str, tool_schema: dict, call_fn: Callable):
|
| 35 |
+
self.server_name = server_name
|
| 36 |
+
self.tool_name = tool_name
|
| 37 |
+
self._schema = tool_schema
|
| 38 |
+
self._call_fn = call_fn
|
| 39 |
+
|
| 40 |
+
# Generate MCP-prefixed name
|
| 41 |
+
self.name = f"mcp_{server_name}_{tool_name}"
|
| 42 |
+
self.description = tool_schema.get("description", f"MCP tool: {tool_name}")
|
| 43 |
+
self.kind = "other"
|
| 44 |
+
self.is_read_only = tool_schema.get("annotations", {}).get("readOnlyHint", False)
|
| 45 |
+
|
| 46 |
+
def get_tool_definition(self) -> dict[str, Any]:
|
| 47 |
+
return {
|
| 48 |
+
"name": self.name,
|
| 49 |
+
"description": self.description,
|
| 50 |
+
"parameters": self._schema.get("inputSchema", {"type": "object", "properties": {}}),
|
| 51 |
+
"kind": self.kind,
|
| 52 |
+
"mcp_server": self.server_name,
|
| 53 |
+
"mcp_tool": self.tool_name,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
def call(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
| 57 |
+
"""Execute the MCP tool call."""
|
| 58 |
+
return self._call_fn(self.tool_name, arguments)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ββ MCP Client Manager βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
|
| 63 |
+
class MCPClientManager:
|
| 64 |
+
"""Manages multiple MCP server connections.
|
| 65 |
+
|
| 66 |
+
Lifecycle: configure β connect (lazy) β use β disconnect
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(self):
|
| 70 |
+
self._configs: dict[str, MCPServerConfig] = {}
|
| 71 |
+
self._clients: dict[str, Any] = {} # server_name β MCP client/session
|
| 72 |
+
self._tools: dict[str, MCPToolBridge] = {}
|
| 73 |
+
self._connected: set[str] = set()
|
| 74 |
+
self._lock = threading.Lock()
|
| 75 |
+
|
| 76 |
+
def configure(self, configs: list[MCPServerConfig]) -> None:
|
| 77 |
+
"""Set MCP server configurations."""
|
| 78 |
+
self._configs = {c.name: c for c in configs if c.enabled}
|
| 79 |
+
|
| 80 |
+
def add_server(self, config: MCPServerConfig) -> None:
|
| 81 |
+
"""Add or update a single MCP server config."""
|
| 82 |
+
self._configs[config.name] = config
|
| 83 |
+
# If already connected to this server, disconnect and reconnect
|
| 84 |
+
if config.name in self._connected:
|
| 85 |
+
self._disconnect(config.name)
|
| 86 |
+
|
| 87 |
+
def remove_server(self, name: str) -> None:
|
| 88 |
+
"""Remove an MCP server."""
|
| 89 |
+
self._configs.pop(name, None)
|
| 90 |
+
self._disconnect(name)
|
| 91 |
+
|
| 92 |
+
def connect_all(self) -> dict[str, str | None]:
|
| 93 |
+
"""Connect to all configured MCP servers. Returns {name: error_or_None}."""
|
| 94 |
+
results = {}
|
| 95 |
+
for name, config in self._configs.items():
|
| 96 |
+
if config.enabled:
|
| 97 |
+
try:
|
| 98 |
+
error = self._connect(name, config)
|
| 99 |
+
results[name] = error
|
| 100 |
+
except Exception as e:
|
| 101 |
+
results[name] = str(e)
|
| 102 |
+
logger.error(f"MCP connect failed for {name}: {e}")
|
| 103 |
+
return results
|
| 104 |
+
|
| 105 |
+
def disconnect_all(self) -> None:
|
| 106 |
+
"""Disconnect all MCP servers."""
|
| 107 |
+
for name in list(self._connected):
|
| 108 |
+
self._disconnect(name)
|
| 109 |
+
|
| 110 |
+
def get_tools(self) -> dict[str, MCPToolBridge]:
|
| 111 |
+
"""Get all discovered MCP tools."""
|
| 112 |
+
return dict(self._tools)
|
| 113 |
+
|
| 114 |
+
def get_tool(self, name: str) -> MCPToolBridge | None:
|
| 115 |
+
"""Get a specific MCP tool by its full name (mcp_server_tool)."""
|
| 116 |
+
return self._tools.get(name)
|
| 117 |
+
|
| 118 |
+
def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
| 119 |
+
"""Call an MCP tool on a specific server."""
|
| 120 |
+
client = self._clients.get(server_name)
|
| 121 |
+
if not client:
|
| 122 |
+
# Try lazy connect
|
| 123 |
+
config = self._configs.get(server_name)
|
| 124 |
+
if not config:
|
| 125 |
+
return {"error": f"MCP server '{server_name}' not configured"}
|
| 126 |
+
error = self._connect(server_name, config)
|
| 127 |
+
if error:
|
| 128 |
+
return {"error": f"Failed to connect to '{server_name}': {error}"}
|
| 129 |
+
client = self._clients.get(server_name)
|
| 130 |
+
if not client:
|
| 131 |
+
return {"error": f"MCP server '{server_name}' connection failed"}
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
if hasattr(client, "call_tool"):
|
| 135 |
+
result = client.call_tool(tool_name, arguments)
|
| 136 |
+
if hasattr(result, "content"):
|
| 137 |
+
# Extract text content from MCP result
|
| 138 |
+
texts = []
|
| 139 |
+
for item in result.content:
|
| 140 |
+
if hasattr(item, "text"):
|
| 141 |
+
texts.append(item.text)
|
| 142 |
+
else:
|
| 143 |
+
texts.append(str(item))
|
| 144 |
+
return {"output": "\n".join(texts)}
|
| 145 |
+
return {"output": str(result)}
|
| 146 |
+
return {"output": str(client)}
|
| 147 |
+
except Exception as e:
|
| 148 |
+
return {"error": f"MCP tool call failed: {e}"}
|
| 149 |
+
|
| 150 |
+
def list_resources(self, server_name: str | None = None) -> list[dict]:
|
| 151 |
+
"""List available MCP resources."""
|
| 152 |
+
resources = []
|
| 153 |
+
servers = [server_name] if server_name else list(self._connected)
|
| 154 |
+
for name in servers:
|
| 155 |
+
client = self._clients.get(name)
|
| 156 |
+
if client and hasattr(client, "list_resources"):
|
| 157 |
+
try:
|
| 158 |
+
result = client.list_resources()
|
| 159 |
+
for r in (result.resources if hasattr(result, "resources") else []):
|
| 160 |
+
resources.append({
|
| 161 |
+
"server": name,
|
| 162 |
+
"uri": getattr(r, "uri", str(r)),
|
| 163 |
+
"name": getattr(r, "name", ""),
|
| 164 |
+
"description": getattr(r, "description", ""),
|
| 165 |
+
})
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.warning(f"Failed to list resources from {name}: {e}")
|
| 168 |
+
return resources
|
| 169 |
+
|
| 170 |
+
def get_status(self) -> dict[str, Any]:
|
| 171 |
+
"""Get status of all MCP servers."""
|
| 172 |
+
status = {}
|
| 173 |
+
for name, config in self._configs.items():
|
| 174 |
+
status[name] = {
|
| 175 |
+
"enabled": config.enabled,
|
| 176 |
+
"connected": name in self._connected,
|
| 177 |
+
"transport": config.transport,
|
| 178 |
+
"tool_count": sum(1 for t in self._tools.values() if t.server_name == name),
|
| 179 |
+
}
|
| 180 |
+
return status
|
| 181 |
+
|
| 182 |
+
# ββ Internal βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
|
| 184 |
+
def _connect(self, name: str, config: MCPServerConfig) -> str | None:
|
| 185 |
+
"""Connect to a single MCP server. Returns error message or None."""
|
| 186 |
+
if name in self._connected:
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
try:
|
| 190 |
+
# Try to import MCP SDK
|
| 191 |
+
try:
|
| 192 |
+
from mcp import ClientSession, StdioServerParameters
|
| 193 |
+
from mcp.client.stdio import stdio_client
|
| 194 |
+
except ImportError:
|
| 195 |
+
logger.info("MCP SDK not installed. Skipping MCP server %s", name)
|
| 196 |
+
return "mcp SDK not installed (pip install mcp)"
|
| 197 |
+
|
| 198 |
+
if config.transport == "stdio":
|
| 199 |
+
server_params = StdioServerParameters(
|
| 200 |
+
command=config.command or "",
|
| 201 |
+
args=config.args,
|
| 202 |
+
env=config.env or None,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Run async connect in a new thread with event loop
|
| 206 |
+
client_session, error = self._run_async_connect(name, server_params)
|
| 207 |
+
if error or not client_session:
|
| 208 |
+
return error or f"Failed to connect to {name}"
|
| 209 |
+
|
| 210 |
+
self._clients[name] = client_session
|
| 211 |
+
self._connected.add(name)
|
| 212 |
+
|
| 213 |
+
# Discover tools
|
| 214 |
+
self._discover_tools(name, client_session)
|
| 215 |
+
logger.info(f"MCP server '{name}' connected with {sum(1 for t in self._tools.values() if t.server_name == name)} tools")
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
elif config.transport == "sse":
|
| 219 |
+
return f"SSE transport not yet supported for {name}"
|
| 220 |
+
|
| 221 |
+
return f"Unknown transport: {config.transport}"
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error(f"MCP connect error for {name}: {e}")
|
| 225 |
+
return str(e)
|
| 226 |
+
|
| 227 |
+
def _run_async_connect(self, name: str, server_params):
|
| 228 |
+
"""Run async MCP connection in a sync context."""
|
| 229 |
+
from mcp import ClientSession, StdioServerParameters
|
| 230 |
+
from mcp.client.stdio import stdio_client
|
| 231 |
+
|
| 232 |
+
result = {"session": None, "error": None}
|
| 233 |
+
|
| 234 |
+
async def _connect_async():
|
| 235 |
+
try:
|
| 236 |
+
async with stdio_client(server_params) as (read, write):
|
| 237 |
+
async with ClientSession(read, write) as session:
|
| 238 |
+
await session.initialize()
|
| 239 |
+
result["session"] = _AsyncSessionWrapper(session)
|
| 240 |
+
# Keep the context managers alive by blocking
|
| 241 |
+
await asyncio.Future() # Block forever
|
| 242 |
+
except Exception as e:
|
| 243 |
+
result["error"] = str(e)
|
| 244 |
+
|
| 245 |
+
loop = asyncio.new_event_loop()
|
| 246 |
+
thread = threading.Thread(target=loop.run_until_complete, args=(_connect_async(),), daemon=True)
|
| 247 |
+
thread.start()
|
| 248 |
+
thread.join(timeout=10)
|
| 249 |
+
|
| 250 |
+
if result["error"] and not result["session"]:
|
| 251 |
+
return None, result["error"]
|
| 252 |
+
if result["session"]:
|
| 253 |
+
return result["session"], None
|
| 254 |
+
return None, "Connection timeout"
|
| 255 |
+
|
| 256 |
+
def _discover_tools(self, server_name: str, client) -> None:
|
| 257 |
+
"""Discover and register tools from an MCP server."""
|
| 258 |
+
# Remove old tools for this server
|
| 259 |
+
old_prefix = f"mcp_{server_name}_"
|
| 260 |
+
for k in list(self._tools.keys()):
|
| 261 |
+
if k.startswith(old_prefix):
|
| 262 |
+
del self._tools[k]
|
| 263 |
+
|
| 264 |
+
# Discover new tools (sync wrapper)
|
| 265 |
+
try:
|
| 266 |
+
tools_result = client.list_tools() if hasattr(client, "list_tools") else None
|
| 267 |
+
tools = tools_result.tools if hasattr(tools_result, "tools") else []
|
| 268 |
+
|
| 269 |
+
for tool in tools:
|
| 270 |
+
schema = {
|
| 271 |
+
"description": getattr(tool, "description", ""),
|
| 272 |
+
"inputSchema": getattr(tool, "inputSchema", {"type": "object", "properties": {}}),
|
| 273 |
+
"annotations": getattr(tool, "annotations", {}),
|
| 274 |
+
}
|
| 275 |
+
tool_name = getattr(tool, "name", "unknown")
|
| 276 |
+
|
| 277 |
+
bridge = MCPToolBridge(
|
| 278 |
+
server_name=server_name,
|
| 279 |
+
tool_name=tool_name,
|
| 280 |
+
tool_schema=schema,
|
| 281 |
+
call_fn=lambda sn=server_name, tn=tool_name, args=None: self.call_tool(sn, tn, args or {}),
|
| 282 |
+
)
|
| 283 |
+
self._tools[bridge.name] = bridge
|
| 284 |
+
logger.debug(f"Discovered MCP tool: {bridge.name}")
|
| 285 |
+
|
| 286 |
+
except Exception as e:
|
| 287 |
+
logger.warning(f"Failed to discover tools from {server_name}: {e}")
|
| 288 |
+
|
| 289 |
+
def _disconnect(self, name: str) -> None:
|
| 290 |
+
"""Disconnect from an MCP server."""
|
| 291 |
+
self._clients.pop(name, None)
|
| 292 |
+
self._connected.discard(name)
|
| 293 |
+
# Remove tools
|
| 294 |
+
prefix = f"mcp_{name}_"
|
| 295 |
+
for k in list(self._tools.keys()):
|
| 296 |
+
if k.startswith(prefix):
|
| 297 |
+
del self._tools[k]
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class _AsyncSessionWrapper:
|
| 301 |
+
"""Wraps an MCP ClientSession for sync access."""
|
| 302 |
+
|
| 303 |
+
def __init__(self, session):
|
| 304 |
+
self._session = session
|
| 305 |
+
|
| 306 |
+
def __getattr__(self, name):
|
| 307 |
+
attr = getattr(self._session, name)
|
| 308 |
+
if asyncio.iscoroutinefunction(attr):
|
| 309 |
+
def sync_wrapper(*args, **kwargs):
|
| 310 |
+
try:
|
| 311 |
+
loop = asyncio.get_event_loop()
|
| 312 |
+
if loop.is_running():
|
| 313 |
+
# Can't await in running loop, return a placeholder
|
| 314 |
+
import concurrent.futures
|
| 315 |
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
| 316 |
+
future = pool.submit(asyncio.run, attr(*args, **kwargs))
|
| 317 |
+
return future.result(timeout=30)
|
| 318 |
+
else:
|
| 319 |
+
return loop.run_until_complete(attr(*args, **kwargs))
|
| 320 |
+
except RuntimeError:
|
| 321 |
+
return asyncio.run(attr(*args, **kwargs))
|
| 322 |
+
return sync_wrapper
|
| 323 |
+
return attr
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ββ Global Manager βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 327 |
+
|
| 328 |
+
_global_manager: MCPClientManager | None = None
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def get_mcp_manager() -> MCPClientManager:
|
| 332 |
+
"""Get or create the global MCP client manager."""
|
| 333 |
+
global _global_manager
|
| 334 |
+
if _global_manager is None:
|
| 335 |
+
_global_manager = MCPClientManager()
|
| 336 |
+
return _global_manager
|
sonicoder/mcp/client_manager.py
ADDED
|
File without changes
|
sonicoder/model/__init__.py
ADDED
|
File without changes
|
sonicoder/model/inference.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming inference engine β supports both text and VLM models.
|
| 2 |
+
|
| 3 |
+
Optimized for 1B models with proper streaming via TextIteratorStreamer.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
import threading
|
| 11 |
+
from typing import Any, Generator
|
| 12 |
+
|
| 13 |
+
from transformers import TextIteratorStreamer
|
| 14 |
+
|
| 15 |
+
from ..config import MODEL_REGISTRY
|
| 16 |
+
from .loader import get_model_and_tokenizer, get_model_status
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# ββ Regex Patterns βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
|
| 22 |
+
THINKING_BLOCK_RE = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
| 23 |
+
CODE_BLOCK_RE = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL)
|
| 24 |
+
FILE_BLOCK_RE = re.compile(r"@@FILE:(.+?)@@\n(.*?)@@END@@", re.DOTALL)
|
| 25 |
+
TOOL_CALL_RE = re.compile(r"```tool\s*\n(.*?)```", re.DOTALL)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _build_text_prompt(messages: list[dict[str, str]], system_prompt: str) -> str:
|
| 29 |
+
"""Build a chat-style prompt for text models.
|
| 30 |
+
|
| 31 |
+
Uses a simple but effective template that works well with Qwen and DeepSeek models.
|
| 32 |
+
"""
|
| 33 |
+
parts = [f"System: {system_prompt}\n"]
|
| 34 |
+
|
| 35 |
+
for msg in messages:
|
| 36 |
+
role = msg.get("role", "user")
|
| 37 |
+
content = msg.get("content", "")
|
| 38 |
+
if role == "system":
|
| 39 |
+
parts.append(f"System: {content}\n")
|
| 40 |
+
elif role == "user":
|
| 41 |
+
parts.append(f"User: {content}\n")
|
| 42 |
+
elif role == "assistant":
|
| 43 |
+
parts.append(f"Assistant: {content}\n")
|
| 44 |
+
elif role == "tool":
|
| 45 |
+
parts.append(f"Tool Result: {content}\n")
|
| 46 |
+
|
| 47 |
+
parts.append("Assistant:")
|
| 48 |
+
return "\n".join(parts)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def call_model_stream(
|
| 52 |
+
messages: list[dict[str, str]],
|
| 53 |
+
system_prompt: str = "",
|
| 54 |
+
max_new_tokens: int = 4096,
|
| 55 |
+
temperature: float = 0.35,
|
| 56 |
+
image_url: str | None = None,
|
| 57 |
+
) -> Generator[str, None, None]:
|
| 58 |
+
"""Stream tokens from the model.
|
| 59 |
+
|
| 60 |
+
Yields partial text strings as they're generated.
|
| 61 |
+
Falls back to non-streaming if TextIteratorStreamer isn't available.
|
| 62 |
+
"""
|
| 63 |
+
status = get_model_status()
|
| 64 |
+
if status["status"] != "ready":
|
| 65 |
+
raise RuntimeError(f"Model not ready: {status}")
|
| 66 |
+
|
| 67 |
+
model_key = status["model_key"]
|
| 68 |
+
config = MODEL_REGISTRY[model_key]
|
| 69 |
+
|
| 70 |
+
if config.model_type == "vlm" and image_url:
|
| 71 |
+
yield from _call_vlm_stream(messages, system_prompt, max_new_tokens, temperature, image_url)
|
| 72 |
+
else:
|
| 73 |
+
yield from _call_text_stream(messages, system_prompt, max_new_tokens, temperature)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _call_text_stream(
|
| 77 |
+
messages: list[dict[str, str]],
|
| 78 |
+
system_prompt: str,
|
| 79 |
+
max_new_tokens: int,
|
| 80 |
+
temperature: float,
|
| 81 |
+
) -> Generator[str, None, None]:
|
| 82 |
+
"""Stream from a text-only model."""
|
| 83 |
+
model, tokenizer = get_model_and_tokenizer()
|
| 84 |
+
|
| 85 |
+
prompt = _build_text_prompt(messages, system_prompt)
|
| 86 |
+
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=32768)
|
| 87 |
+
inputs = {k: v.to(model.device) for k, v in inputs.items()}
|
| 88 |
+
|
| 89 |
+
# Try streaming first
|
| 90 |
+
try:
|
| 91 |
+
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
| 92 |
+
|
| 93 |
+
generation_kwargs = {
|
| 94 |
+
**inputs,
|
| 95 |
+
"max_new_tokens": max_new_tokens,
|
| 96 |
+
"temperature": temperature,
|
| 97 |
+
"do_sample": temperature > 0,
|
| 98 |
+
"top_p": 0.9,
|
| 99 |
+
"top_k": 50,
|
| 100 |
+
"repetition_penalty": 1.1,
|
| 101 |
+
"streamer": streamer,
|
| 102 |
+
"pad_token_id": tokenizer.eos_token_id,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
thread = threading.Thread(
|
| 106 |
+
target=model.generate, kwargs=generation_kwargs, daemon=True
|
| 107 |
+
)
|
| 108 |
+
thread.start()
|
| 109 |
+
|
| 110 |
+
for token in streamer:
|
| 111 |
+
if token:
|
| 112 |
+
yield token
|
| 113 |
+
|
| 114 |
+
thread.join(timeout=60)
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.warning(f"Streaming failed, falling back to non-streaming: {e}")
|
| 118 |
+
# Non-streaming fallback
|
| 119 |
+
with torch.no_grad():
|
| 120 |
+
outputs = model.generate(
|
| 121 |
+
**inputs,
|
| 122 |
+
max_new_tokens=max_new_tokens,
|
| 123 |
+
temperature=temperature,
|
| 124 |
+
do_sample=temperature > 0,
|
| 125 |
+
top_p=0.9,
|
| 126 |
+
top_k=50,
|
| 127 |
+
repetition_penalty=1.1,
|
| 128 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 129 |
+
)
|
| 130 |
+
text = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 131 |
+
yield text
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _call_vlm_stream(
|
| 135 |
+
messages: list[dict[str, str]],
|
| 136 |
+
system_prompt: str,
|
| 137 |
+
max_new_tokens: int,
|
| 138 |
+
temperature: float,
|
| 139 |
+
image_url: str,
|
| 140 |
+
) -> Generator[str, None, None]:
|
| 141 |
+
"""Stream from a vision-language model."""
|
| 142 |
+
import torch
|
| 143 |
+
from PIL import Image
|
| 144 |
+
import requests
|
| 145 |
+
|
| 146 |
+
model, processor = get_model_and_tokenizer()
|
| 147 |
+
|
| 148 |
+
# Build messages for VLM
|
| 149 |
+
vlm_messages = []
|
| 150 |
+
if system_prompt:
|
| 151 |
+
vlm_messages.append({"role": "system", "content": system_prompt})
|
| 152 |
+
|
| 153 |
+
for msg in messages:
|
| 154 |
+
if msg["role"] == "user" and msg is messages[-1]:
|
| 155 |
+
# Add image to last user message
|
| 156 |
+
vlm_messages.append({
|
| 157 |
+
"role": "user",
|
| 158 |
+
"content": [
|
| 159 |
+
{"type": "image", "url": image_url},
|
| 160 |
+
{"type": "text", "text": msg["content"]},
|
| 161 |
+
],
|
| 162 |
+
})
|
| 163 |
+
else:
|
| 164 |
+
vlm_messages.append(msg)
|
| 165 |
+
|
| 166 |
+
# Apply chat template
|
| 167 |
+
try:
|
| 168 |
+
prompt = processor.apply_chat_template(
|
| 169 |
+
vlm_messages, tokenize=False, add_generation_prompt=True
|
| 170 |
+
)
|
| 171 |
+
except Exception:
|
| 172 |
+
prompt = _build_text_prompt(messages, system_prompt)
|
| 173 |
+
|
| 174 |
+
# Process inputs
|
| 175 |
+
image = None
|
| 176 |
+
if image_url:
|
| 177 |
+
try:
|
| 178 |
+
if image_url.startswith(("http://", "https://")):
|
| 179 |
+
resp = requests.get(image_url, timeout=10)
|
| 180 |
+
image = Image.open(__import__("io").BytesIO(resp.content)).convert("RGB")
|
| 181 |
+
else:
|
| 182 |
+
image = Image.open(image_url).convert("RGB")
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.warning(f"Failed to load image: {e}")
|
| 185 |
+
|
| 186 |
+
inputs = processor(text=prompt, images=image, return_tensors="pt", truncation=True)
|
| 187 |
+
inputs = {k: v.to(model.device) for k, v in inputs.items()}
|
| 188 |
+
|
| 189 |
+
# Non-streaming for VLM (most 1B VLMs don't support TextIteratorStreamer well)
|
| 190 |
+
with torch.no_grad():
|
| 191 |
+
outputs = model.generate(
|
| 192 |
+
**inputs,
|
| 193 |
+
max_new_tokens=max_new_tokens,
|
| 194 |
+
temperature=temperature,
|
| 195 |
+
do_sample=temperature > 0,
|
| 196 |
+
pad_token_id=processor.tokenizer.eos_token_id if hasattr(processor, "tokenizer") else 2,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Decode
|
| 200 |
+
if hasattr(processor, "tokenizer"):
|
| 201 |
+
text = processor.tokenizer.decode(
|
| 202 |
+
outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
|
| 203 |
+
)
|
| 204 |
+
elif hasattr(processor, "decode"):
|
| 205 |
+
text = processor.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 206 |
+
else:
|
| 207 |
+
text = str(outputs[0])
|
| 208 |
+
|
| 209 |
+
yield text
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def call_model_sync(
|
| 213 |
+
messages: list[dict[str, str]],
|
| 214 |
+
system_prompt: str = "",
|
| 215 |
+
max_new_tokens: int = 4096,
|
| 216 |
+
temperature: float = 0.35,
|
| 217 |
+
image_url: str | None = None,
|
| 218 |
+
) -> str:
|
| 219 |
+
"""Non-streaming call β collects all tokens and returns the full text."""
|
| 220 |
+
chunks = list(call_model_stream(messages, system_prompt, max_new_tokens, temperature, image_url))
|
| 221 |
+
return "".join(chunks)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# Import torch here to avoid issues
|
| 225 |
+
import torch
|
sonicoder/model/loader.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model loader β multi-model management with hot swapping.
|
| 2 |
+
|
| 3 |
+
Supports multiple smart 1B models. Only one model loaded at a time
|
| 4 |
+
to conserve memory (important for HF Spaces free tier).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import gc
|
| 10 |
+
import logging
|
| 11 |
+
import threading
|
| 12 |
+
from typing import Any, Callable
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
from transformers import AutoConfig
|
| 16 |
+
|
| 17 |
+
from ..config import MODEL_REGISTRY, ModelConfig
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
# ββ Module State βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
|
| 23 |
+
_model = None
|
| 24 |
+
_processor = None
|
| 25 |
+
_tokenizer = None
|
| 26 |
+
_model_key: str | None = None
|
| 27 |
+
_model_loaded = False
|
| 28 |
+
_model_loading = False
|
| 29 |
+
_load_error: str | None = None
|
| 30 |
+
_lock = threading.Lock()
|
| 31 |
+
_on_status_change: list[Callable] = []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _get_device() -> str:
|
| 35 |
+
"""Auto-detect best device."""
|
| 36 |
+
if torch.cuda.is_available():
|
| 37 |
+
return "cuda"
|
| 38 |
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
| 39 |
+
return "mps"
|
| 40 |
+
return "cpu"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _get_dtype(device: str) -> torch.dtype:
|
| 44 |
+
"""Select dtype based on device."""
|
| 45 |
+
return torch.float16 if device == "cuda" else torch.float32
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _unload_model() -> None:
|
| 49 |
+
"""Unload current model and free memory."""
|
| 50 |
+
global _model, _processor, _tokenizer, _model_loaded
|
| 51 |
+
if _model is not None:
|
| 52 |
+
del _model
|
| 53 |
+
_model = None
|
| 54 |
+
if _processor is not None:
|
| 55 |
+
del _processor
|
| 56 |
+
_processor = None
|
| 57 |
+
if _tokenizer is not None:
|
| 58 |
+
del _tokenizer
|
| 59 |
+
_tokenizer = None
|
| 60 |
+
_model_loaded = False
|
| 61 |
+
gc.collect()
|
| 62 |
+
if torch.cuda.is_available():
|
| 63 |
+
torch.cuda.empty_cache()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def load_model(model_key: str) -> None:
|
| 67 |
+
"""Load a model by key. Blocks until loaded."""
|
| 68 |
+
global _model, _processor, _tokenizer, _model_key, _model_loaded, _model_loading, _load_error
|
| 69 |
+
|
| 70 |
+
config_entry = MODEL_REGISTRY.get(model_key)
|
| 71 |
+
if not config_entry:
|
| 72 |
+
raise ValueError(f"Unknown model: {model_key}. Available: {list(MODEL_REGISTRY.keys())}")
|
| 73 |
+
|
| 74 |
+
with _lock:
|
| 75 |
+
if _model_key == model_key and _model_loaded:
|
| 76 |
+
return # Already loaded
|
| 77 |
+
|
| 78 |
+
_model_loading = True
|
| 79 |
+
_load_error = None
|
| 80 |
+
_notify_status()
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
_unload_model()
|
| 84 |
+
|
| 85 |
+
device = _get_device()
|
| 86 |
+
dtype = _get_dtype(device)
|
| 87 |
+
logger.info(f"Loading {config_entry.name} ({config_entry.model_id}) on {device} ({dtype})...")
|
| 88 |
+
|
| 89 |
+
model_kwargs = {
|
| 90 |
+
"trust_remote_code": config_entry.trust_remote_code,
|
| 91 |
+
"low_cpu_mem_usage": True,
|
| 92 |
+
"torch_dtype": dtype,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if device == "cuda":
|
| 96 |
+
model_kwargs["device_map"] = "auto"
|
| 97 |
+
else:
|
| 98 |
+
model_kwargs["device_map"] = None
|
| 99 |
+
|
| 100 |
+
if config_entry.model_type == "vlm":
|
| 101 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 102 |
+
_model = AutoModelForImageTextToText.from_pretrained(
|
| 103 |
+
config_entry.model_id, **model_kwargs
|
| 104 |
+
)
|
| 105 |
+
_processor = AutoProcessor.from_pretrained(
|
| 106 |
+
config_entry.model_id, trust_remote_code=True
|
| 107 |
+
)
|
| 108 |
+
_tokenizer = None
|
| 109 |
+
else:
|
| 110 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 111 |
+
_model = AutoModelForCausalLM.from_pretrained(
|
| 112 |
+
config_entry.model_id, **model_kwargs
|
| 113 |
+
)
|
| 114 |
+
_tokenizer = AutoTokenizer.from_pretrained(
|
| 115 |
+
config_entry.model_id, trust_remote_code=True
|
| 116 |
+
)
|
| 117 |
+
_processor = None
|
| 118 |
+
|
| 119 |
+
if not device == "cuda" and hasattr(_model, "to"):
|
| 120 |
+
_model = _model.to(device)
|
| 121 |
+
|
| 122 |
+
_model.eval()
|
| 123 |
+
_model_key = model_key
|
| 124 |
+
_model_loaded = True
|
| 125 |
+
logger.info(f"Model loaded: {config_entry.name}")
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
_load_error = str(e)
|
| 129 |
+
logger.error(f"Failed to load model: {e}")
|
| 130 |
+
raise
|
| 131 |
+
finally:
|
| 132 |
+
_model_loading = False
|
| 133 |
+
_notify_status()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def start_background_load(model_key: str | None = None) -> None:
|
| 137 |
+
"""Start loading a model in a background thread."""
|
| 138 |
+
key = model_key or "qwen25-coder-1.5b"
|
| 139 |
+
t = threading.Thread(target=load_model, args=(key,), daemon=True)
|
| 140 |
+
t.start()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def switch_model(model_key: str) -> None:
|
| 144 |
+
"""Switch to a different model (loads in background)."""
|
| 145 |
+
start_background_load(model_key)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_model_status() -> dict[str, Any]:
|
| 149 |
+
"""Get current model status."""
|
| 150 |
+
config = MODEL_REGISTRY.get(_model_key) if _model_key else None
|
| 151 |
+
status = "error" if _load_error else ("ready" if _model_loaded else ("loading" if _model_loading else "unknown"))
|
| 152 |
+
return {
|
| 153 |
+
"status": status,
|
| 154 |
+
"model_key": _model_key,
|
| 155 |
+
"model_name": config.name if config else None,
|
| 156 |
+
"model_id": config.model_id if config else None,
|
| 157 |
+
"model_type": config.model_type if config else None,
|
| 158 |
+
"error": _load_error,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def get_model_and_tokenizer() -> tuple:
|
| 163 |
+
"""Get the current model and tokenizer/processor. Returns (model, tokenizer_or_processor)."""
|
| 164 |
+
if not _model_loaded or _model is None:
|
| 165 |
+
raise RuntimeError("Model not loaded")
|
| 166 |
+
return _model, _tokenizer or _processor
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def on_status_change(callback: Callable) -> None:
|
| 170 |
+
"""Register a callback for model status changes."""
|
| 171 |
+
_on_status_change.append(callback)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _notify_status() -> None:
|
| 175 |
+
"""Notify all status change listeners."""
|
| 176 |
+
status = get_model_status()
|
| 177 |
+
for cb in _on_status_change:
|
| 178 |
+
try:
|
| 179 |
+
cb(status)
|
| 180 |
+
except Exception:
|
| 181 |
+
pass
|
sonicoder/plugins/__init__.py
ADDED
|
File without changes
|
sonicoder/policy/__init__.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy engine β rule-based tool approval system.
|
| 2 |
+
|
| 3 |
+
Inspired by Gemini CLI's PolicyEngine with priority-sorted rules
|
| 4 |
+
and pattern matching. Also incorporates Claude Code's allow/ask/deny model.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import fnmatch
|
| 10 |
+
import re
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from enum import Enum
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ApprovalDecision(Enum):
|
| 17 |
+
ALLOW = "allow"
|
| 18 |
+
ASK = "ask"
|
| 19 |
+
DENY = "deny"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class PolicyCheckResult:
|
| 24 |
+
"""Result of a policy check."""
|
| 25 |
+
decision: ApprovalDecision
|
| 26 |
+
reason: str = ""
|
| 27 |
+
matched_rule: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class PolicyEngine:
|
| 31 |
+
"""Rule-based policy engine with priority ordering.
|
| 32 |
+
|
| 33 |
+
Rules are evaluated in priority order (highest first).
|
| 34 |
+
First matching rule wins.
|
| 35 |
+
Default: ASK for interactive, DENY for non-interactive.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
# Built-in safety rules (always evaluated, highest priority)
|
| 39 |
+
BUILTIN_RULES = [
|
| 40 |
+
{
|
| 41 |
+
"tool_name": "bash",
|
| 42 |
+
"args_pattern": r"rm\s+-rf\s+(/|~|\*)",
|
| 43 |
+
"approval": "deny",
|
| 44 |
+
"priority": 1000,
|
| 45 |
+
"description": "Block recursive delete of root/home",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"tool_name": "bash",
|
| 49 |
+
"args_pattern": r"(shutdown|reboot|halt|poweroff)",
|
| 50 |
+
"approval": "deny",
|
| 51 |
+
"priority": 1000,
|
| 52 |
+
"description": "Block system shutdown commands",
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"tool_name": "bash",
|
| 56 |
+
"args_pattern": r"(mkfs|fdisk|parted|dd\s+if=/dev/zero)",
|
| 57 |
+
"approval": "deny",
|
| 58 |
+
"priority": 1000,
|
| 59 |
+
"description": "Block disk destruction commands",
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"tool_name": "bash",
|
| 63 |
+
"args_pattern": r"(chmod\s+-R\s+777|chown\s+-R)",
|
| 64 |
+
"approval": "deny",
|
| 65 |
+
"priority": 900,
|
| 66 |
+
"description": "Block dangerous permission changes",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"tool_name": "write_file",
|
| 70 |
+
"args_pattern": None,
|
| 71 |
+
"approval": "allow",
|
| 72 |
+
"priority": -100,
|
| 73 |
+
"description": "Allow file writes in sandboxed workspace",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"tool_name": "edit_file",
|
| 77 |
+
"args_pattern": None,
|
| 78 |
+
"approval": "allow",
|
| 79 |
+
"priority": -100,
|
| 80 |
+
"description": "Allow file edits in sandboxed workspace",
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"tool_name": "read_file",
|
| 84 |
+
"args_pattern": None,
|
| 85 |
+
"approval": "allow",
|
| 86 |
+
"priority": -200,
|
| 87 |
+
"description": "Allow file reads",
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"tool_name": "glob",
|
| 91 |
+
"args_pattern": None,
|
| 92 |
+
"approval": "allow",
|
| 93 |
+
"priority": -200,
|
| 94 |
+
"description": "Allow glob searches",
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"tool_name": "grep",
|
| 98 |
+
"args_pattern": None,
|
| 99 |
+
"approval": "allow",
|
| 100 |
+
"priority": -200,
|
| 101 |
+
"description": "Allow grep searches",
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"tool_name": "list_dir",
|
| 105 |
+
"args_pattern": None,
|
| 106 |
+
"approval": "allow",
|
| 107 |
+
"priority": -200,
|
| 108 |
+
"description": "Allow directory listing",
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"tool_name": "web_search",
|
| 112 |
+
"args_pattern": None,
|
| 113 |
+
"approval": "allow",
|
| 114 |
+
"priority": -200,
|
| 115 |
+
"description": "Allow web search",
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"tool_name": "web_fetch",
|
| 119 |
+
"args_pattern": None,
|
| 120 |
+
"approval": "allow",
|
| 121 |
+
"priority": -200,
|
| 122 |
+
"description": "Allow web fetch",
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"tool_name": "todo",
|
| 126 |
+
"args_pattern": None,
|
| 127 |
+
"approval": "allow",
|
| 128 |
+
"priority": -200,
|
| 129 |
+
"description": "Allow todo management",
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"tool_name": "snapshot_workspace",
|
| 133 |
+
"args_pattern": None,
|
| 134 |
+
"approval": "allow",
|
| 135 |
+
"priority": -200,
|
| 136 |
+
"description": "Allow workspace snapshot",
|
| 137 |
+
},
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
def __init__(self, interactive: bool = True):
|
| 141 |
+
self._rules: list[dict] = list(self.BUILTIN_RULES)
|
| 142 |
+
self._interactive = interactive
|
| 143 |
+
self._approval_mode: str = "default" # default | auto_edit | yolo
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def approval_mode(self) -> str:
|
| 147 |
+
return self._approval_mode
|
| 148 |
+
|
| 149 |
+
@approval_mode.setter
|
| 150 |
+
def approval_mode(self, mode: str) -> None:
|
| 151 |
+
if mode in ("default", "auto_edit", "yolo"):
|
| 152 |
+
self._approval_mode = mode
|
| 153 |
+
|
| 154 |
+
def add_rule(self, rule: dict) -> None:
|
| 155 |
+
"""Add a custom policy rule."""
|
| 156 |
+
rule.setdefault("priority", 0)
|
| 157 |
+
rule.setdefault("approval", "ask")
|
| 158 |
+
self._rules.append(rule)
|
| 159 |
+
self._rules.sort(key=lambda r: r["priority"], reverse=True)
|
| 160 |
+
|
| 161 |
+
def remove_rule(self, description: str) -> bool:
|
| 162 |
+
"""Remove a rule by description."""
|
| 163 |
+
for i, r in enumerate(self._rules):
|
| 164 |
+
if r.get("description") == description:
|
| 165 |
+
self._rules.pop(i)
|
| 166 |
+
return True
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
def set_rules(self, rules: list[dict]) -> None:
|
| 170 |
+
"""Replace all custom rules (keeps built-in rules)."""
|
| 171 |
+
self._rules = list(self.BUILTIN_RULES)
|
| 172 |
+
for r in rules:
|
| 173 |
+
self.add_rule(r)
|
| 174 |
+
|
| 175 |
+
def check(self, tool_name: str, args: dict[str, Any] | None = None) -> PolicyCheckResult:
|
| 176 |
+
"""Check if a tool call is allowed.
|
| 177 |
+
|
| 178 |
+
Returns the decision and reason from the highest-priority matching rule.
|
| 179 |
+
"""
|
| 180 |
+
args_str = str(args) if args else ""
|
| 181 |
+
|
| 182 |
+
for rule in self._rules:
|
| 183 |
+
# Check tool name match (supports wildcards)
|
| 184 |
+
if not self._match_tool_name(tool_name, rule["tool_name"]):
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
# Check args pattern if specified
|
| 188 |
+
if rule.get("args_pattern"):
|
| 189 |
+
try:
|
| 190 |
+
if not re.search(rule["args_pattern"], args_str, re.IGNORECASE):
|
| 191 |
+
continue
|
| 192 |
+
except re.error:
|
| 193 |
+
continue
|
| 194 |
+
|
| 195 |
+
# Match found β return this rule's decision
|
| 196 |
+
approval = rule["approval"]
|
| 197 |
+
|
| 198 |
+
# YOLO mode: upgrade DENY to ASK (except built-in high-priority)
|
| 199 |
+
if self._approval_mode == "yolo" and rule["priority"] < 900:
|
| 200 |
+
if approval == "deny":
|
| 201 |
+
approval = "ask"
|
| 202 |
+
|
| 203 |
+
# Auto-edit mode: auto-approve file writes/edits
|
| 204 |
+
if self._approval_mode == "auto_edit" and tool_name in ("write_file", "edit_file"):
|
| 205 |
+
approval = "allow"
|
| 206 |
+
|
| 207 |
+
return PolicyCheckResult(
|
| 208 |
+
decision=ApprovalDecision(approval),
|
| 209 |
+
reason=rule.get("description", f"Rule matched: {rule['tool_name']}"),
|
| 210 |
+
matched_rule=rule.get("description"),
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Default: ASK for interactive, DENY for non-interactive
|
| 214 |
+
default = ApprovalDecision.ASK if self._interactive else ApprovalDecision.DENY
|
| 215 |
+
return PolicyCheckResult(
|
| 216 |
+
decision=default,
|
| 217 |
+
reason=f"No matching rule for {tool_name} (default: {default.value})",
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def _match_tool_name(self, name: str, pattern: str) -> bool:
|
| 221 |
+
"""Match tool name against pattern (supports * wildcards)."""
|
| 222 |
+
if pattern == "*":
|
| 223 |
+
return True
|
| 224 |
+
if "*" in pattern:
|
| 225 |
+
# Convert glob to regex
|
| 226 |
+
regex = pattern.replace("*", ".*")
|
| 227 |
+
return bool(re.match(f"^{regex}$", name))
|
| 228 |
+
return name == pattern
|
| 229 |
+
|
| 230 |
+
def get_rules(self) -> list[dict]:
|
| 231 |
+
"""Get all rules for display."""
|
| 232 |
+
return list(self._rules)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ββ Global Engine βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 236 |
+
|
| 237 |
+
_global_engine: PolicyEngine | None = None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def get_policy_engine() -> PolicyEngine:
|
| 241 |
+
"""Get or create the global policy engine."""
|
| 242 |
+
global _global_engine
|
| 243 |
+
if _global_engine is None:
|
| 244 |
+
_global_engine = PolicyEngine()
|
| 245 |
+
return _global_engine
|
sonicoder/server/__init__.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server routes β FastAPI + Gradio API endpoints.
|
| 2 |
+
|
| 3 |
+
All HTTP and Gradio API endpoints for the application.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import uuid
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Generator
|
| 13 |
+
|
| 14 |
+
from ..config import (
|
| 15 |
+
Settings, get_available_models, load_settings,
|
| 16 |
+
WORKSPACE_ROOT, CONFIG_DIR,
|
| 17 |
+
)
|
| 18 |
+
from ..model.loader import (
|
| 19 |
+
get_model_status, start_background_load, switch_model,
|
| 20 |
+
)
|
| 21 |
+
from ..tools import get_tool_registry, ToolRegistry
|
| 22 |
+
from ..tools.fs import BUILTIN_TOOLS as FS_TOOLS
|
| 23 |
+
from ..tools.bash import BUILTIN_TOOLS as BASH_TOOLS
|
| 24 |
+
from ..tools.web import BUILTIN_TOOLS as WEB_TOOLS
|
| 25 |
+
from ..tools.todos import BUILTIN_TOOLS as TODO_TOOLS
|
| 26 |
+
from ..agent import AgentSession, register_builtin_tools
|
| 27 |
+
from ..mcp import get_mcp_manager
|
| 28 |
+
from ..policy import get_policy_engine
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# ββ Global State βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
_settings: Settings | None = None
|
| 35 |
+
_registry: ToolRegistry | None = None
|
| 36 |
+
_sessions: dict[str, AgentSession] = {}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _get_settings() -> Settings:
|
| 40 |
+
global _settings
|
| 41 |
+
if _settings is None:
|
| 42 |
+
_settings = load_settings()
|
| 43 |
+
return _settings
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _get_registry() -> ToolRegistry:
|
| 47 |
+
global _registry
|
| 48 |
+
if _registry is None:
|
| 49 |
+
_registry = get_tool_registry()
|
| 50 |
+
register_builtin_tools(_registry)
|
| 51 |
+
# Apply filters
|
| 52 |
+
settings = _get_settings()
|
| 53 |
+
_registry.set_filters(settings.enabled_tools, settings.disabled_tools)
|
| 54 |
+
return _registry
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _get_or_create_session(session_id: str = "") -> tuple[str, AgentSession]:
|
| 58 |
+
"""Get existing session or create new one."""
|
| 59 |
+
if not session_id:
|
| 60 |
+
session_id = str(uuid.uuid4())[:12]
|
| 61 |
+
if session_id not in _sessions:
|
| 62 |
+
_sessions[session_id] = AgentSession(
|
| 63 |
+
settings=_get_settings(),
|
| 64 |
+
tool_registry=_get_registry(),
|
| 65 |
+
)
|
| 66 |
+
return session_id, _sessions[session_id]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ββ Gradio API Functions ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
|
| 71 |
+
def api_chat(
|
| 72 |
+
message: str,
|
| 73 |
+
session_id: str = "",
|
| 74 |
+
image_url: str | None = None,
|
| 75 |
+
) -> Generator[str, None, None]:
|
| 76 |
+
"""Main chat/agent endpoint β streams events as JSON lines."""
|
| 77 |
+
if not message or not message.strip():
|
| 78 |
+
yield json.dumps({"type": "error", "error": "Empty message"})
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
sid, session = _get_or_create_session(session_id)
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
for event in session.run(message, session_id=sid, image_url=image_url):
|
| 85 |
+
yield json.dumps(event, ensure_ascii=False)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"Chat error: {e}", exc_info=True)
|
| 88 |
+
yield json.dumps({"type": "error", "error": str(e)})
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def api_chat_sync(message: str, session_id: str = "") -> str:
|
| 92 |
+
"""Non-streaming chat for simple calls."""
|
| 93 |
+
events = list(api_chat(message, session_id))
|
| 94 |
+
if not events:
|
| 95 |
+
return json.dumps({"type": "error", "error": "No response"})
|
| 96 |
+
return events[-1] # Return last event (complete)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def api_model_status() -> str:
|
| 100 |
+
"""Get model loading status."""
|
| 101 |
+
return json.dumps(get_model_status())
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def api_switch_model(model_key: str) -> str:
|
| 105 |
+
"""Switch to a different model."""
|
| 106 |
+
switch_model(model_key)
|
| 107 |
+
return json.dumps({"status": "switching", "model": model_key})
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def api_list_models() -> str:
|
| 111 |
+
"""List all available models."""
|
| 112 |
+
models = get_available_models()
|
| 113 |
+
current = get_model_status().get("model_key", "")
|
| 114 |
+
result = []
|
| 115 |
+
for key, config in models.items():
|
| 116 |
+
result.append({
|
| 117 |
+
"key": key,
|
| 118 |
+
"name": config.name,
|
| 119 |
+
"model_type": config.model_type,
|
| 120 |
+
"size_gb": config.size_gb,
|
| 121 |
+
"description": config.description,
|
| 122 |
+
"current": key == current,
|
| 123 |
+
})
|
| 124 |
+
return json.dumps(result, ensure_ascii=False)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def api_workspace_tree(max_depth: int = 3) -> str:
|
| 128 |
+
"""Get workspace directory tree."""
|
| 129 |
+
import os
|
| 130 |
+
tree = []
|
| 131 |
+
|
| 132 |
+
def walk(path: Path, prefix: str = "", depth: int = 0):
|
| 133 |
+
if depth >= max_depth:
|
| 134 |
+
return
|
| 135 |
+
try:
|
| 136 |
+
items = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
| 137 |
+
except PermissionError:
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
skip = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".sonicoder"}
|
| 141 |
+
for i, item in enumerate(items):
|
| 142 |
+
if item.name.startswith(".") or item.name in skip:
|
| 143 |
+
continue
|
| 144 |
+
is_last = i == len(items) - 1
|
| 145 |
+
connector = "βββ " if is_last else "βββ "
|
| 146 |
+
if item.is_dir():
|
| 147 |
+
tree.append(f"{prefix}{connector}{item.name}/")
|
| 148 |
+
extension = " " if is_last else "β "
|
| 149 |
+
walk(item, prefix + extension, depth + 1)
|
| 150 |
+
else:
|
| 151 |
+
tree.append(f"{prefix}{connector}{item.name}")
|
| 152 |
+
|
| 153 |
+
if WORKSPACE_ROOT.exists():
|
| 154 |
+
walk(WORKSPACE_ROOT)
|
| 155 |
+
|
| 156 |
+
return json.dumps({
|
| 157 |
+
"tree": "\n".join(tree) if tree else "(empty workspace)",
|
| 158 |
+
"root": str(WORKSPACE_ROOT),
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def api_workspace_read(path: str, offset: int = 1, limit: int = 500) -> str:
|
| 163 |
+
"""Read a workspace file."""
|
| 164 |
+
from ..tools.fs import _resolve_safe
|
| 165 |
+
try:
|
| 166 |
+
resolved = _resolve_safe(path)
|
| 167 |
+
if not resolved.exists():
|
| 168 |
+
return json.dumps({"error": f"File not found: {path}"})
|
| 169 |
+
content = resolved.read_text(encoding="utf-8", errors="replace")
|
| 170 |
+
lines = content.splitlines()
|
| 171 |
+
total = len(lines)
|
| 172 |
+
start = max(0, offset - 1)
|
| 173 |
+
end = min(total, start + limit)
|
| 174 |
+
selected = lines[start:end]
|
| 175 |
+
numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
|
| 176 |
+
return json.dumps({
|
| 177 |
+
"content": "\n".join(numbered),
|
| 178 |
+
"total_lines": total,
|
| 179 |
+
"truncated": end < total,
|
| 180 |
+
})
|
| 181 |
+
except Exception as e:
|
| 182 |
+
return json.dumps({"error": str(e)})
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def api_workspace_write(path: str, content: str) -> str:
|
| 186 |
+
"""Write a workspace file."""
|
| 187 |
+
from ..tools.fs import _resolve_safe
|
| 188 |
+
try:
|
| 189 |
+
resolved = _resolve_safe(path)
|
| 190 |
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
| 191 |
+
resolved.write_text(content, encoding="utf-8")
|
| 192 |
+
return json.dumps({"success": True, "path": path})
|
| 193 |
+
except Exception as e:
|
| 194 |
+
return json.dumps({"error": str(e)})
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def api_workspace_bash(command: str) -> str:
|
| 198 |
+
"""Execute a bash command in workspace."""
|
| 199 |
+
from ..tools.bash import BashBuilder
|
| 200 |
+
builder = BashBuilder()
|
| 201 |
+
errors = builder.validate({"command": command})
|
| 202 |
+
if errors:
|
| 203 |
+
return json.dumps({"error": "; ".join(errors)})
|
| 204 |
+
invocation = builder.create_invocation({"command": command})
|
| 205 |
+
result = invocation.execute()
|
| 206 |
+
return json.dumps(result.to_dict())
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def api_web_search(query: str) -> str:
|
| 210 |
+
"""Search the web."""
|
| 211 |
+
from ..tools.web import WebSearchBuilder
|
| 212 |
+
builder = WebSearchBuilder()
|
| 213 |
+
invocation = builder.create_invocation({"query": query})
|
| 214 |
+
result = invocation.execute()
|
| 215 |
+
return json.dumps(result.to_dict(), ensure_ascii=False)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def api_mcp_status() -> str:
|
| 219 |
+
"""Get MCP server status."""
|
| 220 |
+
manager = get_mcp_manager()
|
| 221 |
+
return json.dumps(manager.get_status())
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def api_policy_status() -> str:
|
| 225 |
+
"""Get policy engine status."""
|
| 226 |
+
engine = get_policy_engine()
|
| 227 |
+
return json.dumps({
|
| 228 |
+
"mode": engine.approval_mode,
|
| 229 |
+
"rules": engine.get_rules(),
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def api_reset_session(session_id: str = "") -> str:
|
| 234 |
+
"""Reset a chat session."""
|
| 235 |
+
if session_id in _sessions:
|
| 236 |
+
_sessions[session_id].clear_history()
|
| 237 |
+
return json.dumps({"success": True})
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def api_get_config() -> str:
|
| 241 |
+
"""Get runtime configuration for the frontend."""
|
| 242 |
+
models = get_available_models()
|
| 243 |
+
model_list = []
|
| 244 |
+
for key, config in models.items():
|
| 245 |
+
model_list.append({
|
| 246 |
+
"key": key,
|
| 247 |
+
"name": config.name,
|
| 248 |
+
"model_type": config.model_type,
|
| 249 |
+
"size_gb": config.size_gb,
|
| 250 |
+
"description": config.description,
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
return json.dumps({
|
| 254 |
+
"models": model_list,
|
| 255 |
+
"default_model": _get_settings().default_model,
|
| 256 |
+
"workspace": str(WORKSPACE_ROOT),
|
| 257 |
+
"version": "2.0.0",
|
| 258 |
+
}, ensure_ascii=False)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# ββ Create Gradio App ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 262 |
+
|
| 263 |
+
def get_app():
|
| 264 |
+
"""Create and return the Gradio application."""
|
| 265 |
+
import gradio as gr
|
| 266 |
+
from fastapi.responses import FileResponse
|
| 267 |
+
from pathlib import Path
|
| 268 |
+
|
| 269 |
+
# Create the Gradio app
|
| 270 |
+
app = gr.Blocks(
|
| 271 |
+
title="SoniCoder v2 β AI Code Agent",
|
| 272 |
+
theme=gr.themes.Soft(),
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
with app:
|
| 276 |
+
# Hidden components for API
|
| 277 |
+
api_chat_output = gr.Textbox(visible=False)
|
| 278 |
+
api_status_output = gr.Textbox(visible=False)
|
| 279 |
+
api_json_output = gr.Textbox(visible=False)
|
| 280 |
+
|
| 281 |
+
# API endpoints
|
| 282 |
+
gr.Interface(
|
| 283 |
+
fn=api_chat,
|
| 284 |
+
inputs=[
|
| 285 |
+
gr.Textbox(label="message"),
|
| 286 |
+
gr.Textbox(label="session_id", default=""),
|
| 287 |
+
gr.Textbox(label="image_url", default=""),
|
| 288 |
+
],
|
| 289 |
+
outputs=[api_chat_output],
|
| 290 |
+
api_name="chat",
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
gr.Interface(
|
| 294 |
+
fn=api_model_status,
|
| 295 |
+
inputs=[],
|
| 296 |
+
outputs=[api_json_output],
|
| 297 |
+
api_name="model_status",
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
gr.Interface(
|
| 301 |
+
fn=api_switch_model,
|
| 302 |
+
inputs=[gr.Textbox(label="model_key")],
|
| 303 |
+
outputs=[api_json_output],
|
| 304 |
+
api_name="switch_model",
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
gr.Interface(
|
| 308 |
+
fn=api_list_models,
|
| 309 |
+
inputs=[],
|
| 310 |
+
outputs=[api_json_output],
|
| 311 |
+
api_name="list_models",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
gr.Interface(
|
| 315 |
+
fn=api_workspace_tree,
|
| 316 |
+
inputs=[gr.Number(label="max_depth", value=3)],
|
| 317 |
+
outputs=[api_json_output],
|
| 318 |
+
api_name="workspace_tree",
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
gr.Interface(
|
| 322 |
+
fn=api_workspace_read,
|
| 323 |
+
inputs=[
|
| 324 |
+
gr.Textbox(label="path"),
|
| 325 |
+
gr.Number(label="offset", value=1),
|
| 326 |
+
gr.Number(label="limit", value=500),
|
| 327 |
+
],
|
| 328 |
+
outputs=[api_json_output],
|
| 329 |
+
api_name="workspace_read",
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
gr.Interface(
|
| 333 |
+
fn=api_workspace_write,
|
| 334 |
+
inputs=[gr.Textbox(label="path"), gr.Textbox(label="content", lines=10)],
|
| 335 |
+
outputs=[api_json_output],
|
| 336 |
+
api_name="workspace_write",
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
gr.Interface(
|
| 340 |
+
fn=api_workspace_bash,
|
| 341 |
+
inputs=[gr.Textbox(label="command")],
|
| 342 |
+
outputs=[api_json_output],
|
| 343 |
+
api_name="workspace_bash",
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
gr.Interface(
|
| 347 |
+
fn=api_web_search,
|
| 348 |
+
inputs=[gr.Textbox(label="query")],
|
| 349 |
+
outputs=[api_json_output],
|
| 350 |
+
api_name="web_search",
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
gr.Interface(
|
| 354 |
+
fn=api_mcp_status,
|
| 355 |
+
inputs=[],
|
| 356 |
+
outputs=[api_json_output],
|
| 357 |
+
api_name="mcp_status",
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
gr.Interface(
|
| 361 |
+
fn=api_policy_status,
|
| 362 |
+
inputs=[],
|
| 363 |
+
outputs=[api_json_output],
|
| 364 |
+
api_name="policy_status",
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
gr.Interface(
|
| 368 |
+
fn=api_reset_session,
|
| 369 |
+
inputs=[gr.Textbox(label="session_id", default="")],
|
| 370 |
+
outputs=[api_json_output],
|
| 371 |
+
api_name="reset_session",
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
gr.Interface(
|
| 375 |
+
fn=api_get_config,
|
| 376 |
+
inputs=[],
|
| 377 |
+
outputs=[api_json_output],
|
| 378 |
+
api_name="get_config",
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
return app
|
sonicoder/skills/__init__.py
ADDED
|
File without changes
|
sonicoder/tools/__init__.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool system β ToolBuilder/ToolInvocation pattern from Gemini CLI.
|
| 2 |
+
|
| 3 |
+
Every tool follows:
|
| 4 |
+
1. Define a class extending ToolBuilder
|
| 5 |
+
2. Implement get_schema(), validate(), create_invocation()
|
| 6 |
+
3. The invocation's execute() does the actual work
|
| 7 |
+
4. Register in ToolRegistry
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
from abc import ABC, abstractmethod
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from enum import Enum
|
| 16 |
+
from typing import Any, Callable
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ββ Tool Kinds ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
|
| 21 |
+
class ToolKind(Enum):
|
| 22 |
+
READ = "read"
|
| 23 |
+
WRITE = "write"
|
| 24 |
+
EDIT = "edit"
|
| 25 |
+
SHELL = "shell"
|
| 26 |
+
OTHER = "other"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ββ Result Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class ToolResult:
|
| 33 |
+
"""Result of a tool execution."""
|
| 34 |
+
success: bool
|
| 35 |
+
output: str = ""
|
| 36 |
+
error: str | None = None
|
| 37 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 38 |
+
|
| 39 |
+
def to_dict(self) -> dict[str, Any]:
|
| 40 |
+
d: dict[str, Any] = {"success": self.success}
|
| 41 |
+
if self.output:
|
| 42 |
+
d["output"] = self.output
|
| 43 |
+
if self.error:
|
| 44 |
+
d["error"] = self.error
|
| 45 |
+
if self.metadata:
|
| 46 |
+
d["metadata"] = self.metadata
|
| 47 |
+
return d
|
| 48 |
+
|
| 49 |
+
def to_json(self) -> str:
|
| 50 |
+
return json.dumps(self.to_dict(), ensure_ascii=False)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ββ Tool Builder (Schema + Validation + Invocation Factory) βββββββββββ
|
| 54 |
+
|
| 55 |
+
class ToolBuilder(ABC):
|
| 56 |
+
"""Base class for all tools β Gemini CLI pattern.
|
| 57 |
+
|
| 58 |
+
Tools are defined by:
|
| 59 |
+
- name: LLM-visible identifier
|
| 60 |
+
- description: What the tool does
|
| 61 |
+
- kind: READ/WRITE/EDIT/SHELL/OTHER
|
| 62 |
+
- get_schema(): JSON Schema for parameters
|
| 63 |
+
- validate(): Parameter validation
|
| 64 |
+
- create_invocation(): Factory for ToolInvocation
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
name: str = ""
|
| 68 |
+
description: str = ""
|
| 69 |
+
kind: ToolKind = ToolKind.OTHER
|
| 70 |
+
is_read_only: bool = False
|
| 71 |
+
|
| 72 |
+
@abstractmethod
|
| 73 |
+
def get_schema(self) -> dict[str, Any]:
|
| 74 |
+
"""Return JSON Schema for this tool's parameters."""
|
| 75 |
+
...
|
| 76 |
+
|
| 77 |
+
def get_display_name(self) -> str:
|
| 78 |
+
return self.name.replace("_", " ").title()
|
| 79 |
+
|
| 80 |
+
def get_tool_definition(self) -> dict[str, Any]:
|
| 81 |
+
"""Full tool definition for the LLM."""
|
| 82 |
+
return {
|
| 83 |
+
"name": self.name,
|
| 84 |
+
"description": self.description,
|
| 85 |
+
"parameters": self.get_schema(),
|
| 86 |
+
"kind": self.kind.value,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
def validate(self, params: dict[str, Any]) -> list[str]:
|
| 90 |
+
"""Validate params. Returns list of error messages (empty = valid)."""
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
@abstractmethod
|
| 94 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 95 |
+
"""Create an invocation with validated params."""
|
| 96 |
+
...
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ββ Tool Invocation (Ready-to-Execute) ββββββββββββββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
class ToolInvocation(ABC):
|
| 102 |
+
"""A validated, ready-to-execute tool call β Gemini CLI pattern."""
|
| 103 |
+
|
| 104 |
+
def __init__(self, params: dict[str, Any]):
|
| 105 |
+
self.params = params
|
| 106 |
+
|
| 107 |
+
def get_description(self) -> str:
|
| 108 |
+
"""Human-readable description of this invocation."""
|
| 109 |
+
return f"Execute {self.__class__.__name__}"
|
| 110 |
+
|
| 111 |
+
def get_affected_paths(self) -> list[str]:
|
| 112 |
+
"""Return file paths affected by this invocation."""
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
@abstractmethod
|
| 116 |
+
def execute(self) -> ToolResult:
|
| 117 |
+
"""Execute the tool and return a result."""
|
| 118 |
+
...
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ββ Tool Registry βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
class ToolRegistry:
|
| 124 |
+
"""Central registry for all tools β manages discovery, filtering, and lookup.
|
| 125 |
+
|
| 126 |
+
Inspired by Gemini CLI's ToolRegistry with enable/disable support.
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
def __init__(self):
|
| 130 |
+
self._tools: dict[str, ToolBuilder] = {}
|
| 131 |
+
self._mcp_tools: dict[str, ToolBuilder] = {}
|
| 132 |
+
self._enabled_patterns: list[str] = ["*"]
|
| 133 |
+
self._disabled_patterns: list[str] = []
|
| 134 |
+
|
| 135 |
+
def register(self, tool: ToolBuilder) -> None:
|
| 136 |
+
"""Register a built-in tool."""
|
| 137 |
+
self._tools[tool.name] = tool
|
| 138 |
+
|
| 139 |
+
def register_mcp(self, tool: ToolBuilder) -> None:
|
| 140 |
+
"""Register an MCP-discovered tool (prefixed with mcp_)."""
|
| 141 |
+
self._mcp_tools[tool.name] = tool
|
| 142 |
+
|
| 143 |
+
def unregister_mcp(self, server_name: str) -> None:
|
| 144 |
+
"""Unregister all MCP tools from a specific server."""
|
| 145 |
+
prefix = f"mcp_{server_name}_"
|
| 146 |
+
to_remove = [k for k in self._mcp_tools if k.startswith(prefix)]
|
| 147 |
+
for k in to_remove:
|
| 148 |
+
del self._mcp_tools[k]
|
| 149 |
+
|
| 150 |
+
def get_tool(self, name: str) -> ToolBuilder | None:
|
| 151 |
+
"""Look up a tool by name."""
|
| 152 |
+
if name in self._mcp_tools:
|
| 153 |
+
return self._mcp_tools[name]
|
| 154 |
+
return self._tools.get(name)
|
| 155 |
+
|
| 156 |
+
def get_all_tools(self) -> dict[str, ToolBuilder]:
|
| 157 |
+
"""Get all registered tools (built-in + MCP)."""
|
| 158 |
+
return {**self._tools, **self._mcp_tools}
|
| 159 |
+
|
| 160 |
+
def get_active_tools(self) -> dict[str, ToolBuilder]:
|
| 161 |
+
"""Get tools that pass the enable/disable filters."""
|
| 162 |
+
all_tools = self.get_all_tools()
|
| 163 |
+
active = {}
|
| 164 |
+
for name, tool in all_tools.items():
|
| 165 |
+
if self._is_tool_enabled(name):
|
| 166 |
+
active[name] = tool
|
| 167 |
+
return active
|
| 168 |
+
|
| 169 |
+
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
| 170 |
+
"""Get schemas for all active tools (for LLM context)."""
|
| 171 |
+
active = self.get_active_tools()
|
| 172 |
+
return [tool.get_tool_definition() for tool in active.values()]
|
| 173 |
+
|
| 174 |
+
def set_filters(self, enabled: list[str], disabled: list[str]) -> None:
|
| 175 |
+
"""Set enable/disable patterns."""
|
| 176 |
+
self._enabled_patterns = enabled
|
| 177 |
+
self._disabled_patterns = disabled
|
| 178 |
+
|
| 179 |
+
def _is_tool_enabled(self, name: str) -> bool:
|
| 180 |
+
"""Check if a tool passes the filters."""
|
| 181 |
+
# Check explicit disable first
|
| 182 |
+
for pattern in self._disabled_patterns:
|
| 183 |
+
if self._match_pattern(name, pattern):
|
| 184 |
+
return False
|
| 185 |
+
# Check enable patterns
|
| 186 |
+
if "*" in self._enabled_patterns:
|
| 187 |
+
return True
|
| 188 |
+
for pattern in self._enabled_patterns:
|
| 189 |
+
if self._match_pattern(name, pattern):
|
| 190 |
+
return True
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
@staticmethod
|
| 194 |
+
def _match_pattern(name: str, pattern: str) -> bool:
|
| 195 |
+
"""Simple wildcard matching: * at start/end."""
|
| 196 |
+
if pattern == "*":
|
| 197 |
+
return True
|
| 198 |
+
if pattern.endswith("*"):
|
| 199 |
+
return name.startswith(pattern[:-1])
|
| 200 |
+
if pattern.startswith("*"):
|
| 201 |
+
return name.endswith(pattern[1:])
|
| 202 |
+
return name == pattern
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ββ Global Registry βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
|
| 207 |
+
_global_registry: ToolRegistry | None = None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def get_tool_registry() -> ToolRegistry:
|
| 211 |
+
"""Get or create the global tool registry."""
|
| 212 |
+
global _global_registry
|
| 213 |
+
if _global_registry is None:
|
| 214 |
+
_global_registry = ToolRegistry()
|
| 215 |
+
return _global_registry
|
sonicoder/tools/bash.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bash/shell execution tool β sandboxed subprocess execution.
|
| 2 |
+
|
| 3 |
+
Inspired by Claude Code's Bash tool and Gemini CLI's run_shell_command.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import subprocess
|
| 10 |
+
import threading
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
|
| 14 |
+
|
| 15 |
+
# ββ Security βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
|
| 17 |
+
_BLOCKED_PATTERNS = [
|
| 18 |
+
"rm -rf /", "rm -rf ~", "rm -rf /*", "mkfs", "dd if=/dev/zero",
|
| 19 |
+
"shutdown", "reboot", "halt", "poweroff", ":(){ :|:& };:",
|
| 20 |
+
"chmod -R 777 /", "chown -R", "> /dev/sd", "mkswap",
|
| 21 |
+
"fdisk", "parted", "wipefs",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
_SENSITIVE_ENV_VARS = [
|
| 25 |
+
"HF_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY",
|
| 26 |
+
"GEMINI_API_KEY", "AWS_SECRET_ACCESS_KEY", "AWS_ACCESS_KEY_ID",
|
| 27 |
+
"AZURE_API_KEY", "DEEPSEEK_API_KEY",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _is_blocked(command: str) -> str | None:
|
| 32 |
+
"""Check if command matches a blocked pattern. Returns reason or None."""
|
| 33 |
+
cmd_lower = command.strip().lower()
|
| 34 |
+
for pattern in _BLOCKED_PATTERNS:
|
| 35 |
+
if pattern in cmd_lower:
|
| 36 |
+
return f"Blocked: dangerous command pattern '{pattern}'"
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _scrub_env() -> dict[str, str]:
|
| 41 |
+
"""Return a clean environment with sensitive vars removed."""
|
| 42 |
+
env = os.environ.copy()
|
| 43 |
+
for var in _SENSITIVE_ENV_VARS:
|
| 44 |
+
env.pop(var, None)
|
| 45 |
+
return env
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ββ Bash Tool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
class BashBuilder(ToolBuilder):
|
| 51 |
+
name = "bash"
|
| 52 |
+
description = (
|
| 53 |
+
"Execute a shell command in the workspace. Supports any bash command. "
|
| 54 |
+
"Commands are sandboxed and sensitive environment variables are scrubbed. "
|
| 55 |
+
"Timeout: 120 seconds by default."
|
| 56 |
+
)
|
| 57 |
+
kind = ToolKind.SHELL
|
| 58 |
+
|
| 59 |
+
def get_schema(self) -> dict[str, Any]:
|
| 60 |
+
return {
|
| 61 |
+
"type": "object",
|
| 62 |
+
"properties": {
|
| 63 |
+
"command": {"type": "string", "description": "Shell command to execute"},
|
| 64 |
+
"timeout": {"type": "integer", "description": "Timeout in seconds (max 300)", "default": 120},
|
| 65 |
+
},
|
| 66 |
+
"required": ["command"],
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
def validate(self, params: dict[str, Any]) -> list[str]:
|
| 70 |
+
errors = []
|
| 71 |
+
if not params.get("command"):
|
| 72 |
+
errors.append("command is required")
|
| 73 |
+
blocked = _is_blocked(params.get("command", ""))
|
| 74 |
+
if blocked:
|
| 75 |
+
errors.append(blocked)
|
| 76 |
+
return errors
|
| 77 |
+
|
| 78 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 79 |
+
return BashInvocation(params)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class BashInvocation(ToolInvocation):
|
| 83 |
+
MAX_OUTPUT = 50000
|
| 84 |
+
|
| 85 |
+
def get_description(self) -> str:
|
| 86 |
+
cmd = self.params["command"][:80]
|
| 87 |
+
return f"Bash: {cmd}"
|
| 88 |
+
|
| 89 |
+
def execute(self) -> ToolResult:
|
| 90 |
+
command = self.params["command"]
|
| 91 |
+
timeout = min(self.params.get("timeout", 120) or 120, 300)
|
| 92 |
+
|
| 93 |
+
# Security check
|
| 94 |
+
blocked = _is_blocked(command)
|
| 95 |
+
if blocked:
|
| 96 |
+
return ToolResult(success=False, error=blocked)
|
| 97 |
+
|
| 98 |
+
workspace = os.environ.get("SONICODER_WORKSPACE", "./workspace")
|
| 99 |
+
env = _scrub_env()
|
| 100 |
+
env["SONICODER_WORKSPACE"] = workspace
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
proc = subprocess.run(
|
| 104 |
+
["bash", "-c", command],
|
| 105 |
+
capture_output=True,
|
| 106 |
+
text=True,
|
| 107 |
+
timeout=timeout,
|
| 108 |
+
cwd=workspace,
|
| 109 |
+
env=env,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
stdout = proc.stdout or ""
|
| 113 |
+
stderr = proc.stderr or ""
|
| 114 |
+
combined = stdout
|
| 115 |
+
if stderr:
|
| 116 |
+
combined += ("\n--- stderr ---\n" + stderr) if stdout else stderr
|
| 117 |
+
|
| 118 |
+
# Truncate large output
|
| 119 |
+
if len(combined) > self.MAX_OUTPUT:
|
| 120 |
+
combined = combined[:self.MAX_OUTPUT] + f"\n... [truncated, total {len(proc.stdout + proc.stderr)} chars]"
|
| 121 |
+
|
| 122 |
+
if proc.returncode == 0:
|
| 123 |
+
return ToolResult(
|
| 124 |
+
success=True,
|
| 125 |
+
output=combined or "(no output)",
|
| 126 |
+
metadata={"exit_code": 0},
|
| 127 |
+
)
|
| 128 |
+
else:
|
| 129 |
+
return ToolResult(
|
| 130 |
+
success=False,
|
| 131 |
+
output=combined,
|
| 132 |
+
error=f"Exit code: {proc.returncode}",
|
| 133 |
+
metadata={"exit_code": proc.returncode},
|
| 134 |
+
)
|
| 135 |
+
except subprocess.TimeoutExpired:
|
| 136 |
+
return ToolResult(success=False, error=f"Command timed out after {timeout}s")
|
| 137 |
+
except Exception as e:
|
| 138 |
+
return ToolResult(success=False, error=f"Execution error: {e}")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ββ Register βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 142 |
+
|
| 143 |
+
BUILTIN_TOOLS = [BashBuilder()]
|
sonicoder/tools/fs.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File system tools β read, write, edit, glob, grep, list_dir.
|
| 2 |
+
|
| 3 |
+
Inspired by Claude Code's FileRead/FileWrite/FileEdit and Gemini CLI's file tools.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import fnmatch
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
|
| 15 |
+
|
| 16 |
+
WORKSPACE = Path(os.environ.get("SONICODER_WORKSPACE", "./workspace")).resolve()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _resolve_safe(path: str) -> Path:
|
| 20 |
+
"""Resolve a path and ensure it stays within the workspace sandbox."""
|
| 21 |
+
if not path:
|
| 22 |
+
path = "."
|
| 23 |
+
p = Path(path)
|
| 24 |
+
if not p.is_absolute():
|
| 25 |
+
p = WORKSPACE / p
|
| 26 |
+
try:
|
| 27 |
+
p = p.resolve()
|
| 28 |
+
except (OSError, ValueError):
|
| 29 |
+
p = WORKSPACE / path
|
| 30 |
+
# Security: must be under workspace
|
| 31 |
+
try:
|
| 32 |
+
p.relative_to(WORKSPACE)
|
| 33 |
+
except ValueError:
|
| 34 |
+
raise ValueError(f"Path '{path}' escapes workspace")
|
| 35 |
+
return p
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ββ Read File ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
class ReadFileBuilder(ToolBuilder):
|
| 41 |
+
name = "read_file"
|
| 42 |
+
description = (
|
| 43 |
+
"Read file contents with line numbers. Supports offset/limit for pagination. "
|
| 44 |
+
"Returns content, line count, and truncation info."
|
| 45 |
+
)
|
| 46 |
+
kind = ToolKind.READ
|
| 47 |
+
is_read_only = True
|
| 48 |
+
|
| 49 |
+
def get_schema(self) -> dict[str, Any]:
|
| 50 |
+
return {
|
| 51 |
+
"type": "object",
|
| 52 |
+
"properties": {
|
| 53 |
+
"path": {"type": "string", "description": "File path relative to workspace root"},
|
| 54 |
+
"offset": {"type": "integer", "description": "Start line (1-indexed)", "default": 1},
|
| 55 |
+
"limit": {"type": "integer", "description": "Max lines to return", "default": 500},
|
| 56 |
+
},
|
| 57 |
+
"required": ["path"],
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
def validate(self, params: dict[str, Any]) -> list[str]:
|
| 61 |
+
errors = []
|
| 62 |
+
if not params.get("path"):
|
| 63 |
+
errors.append("path is required")
|
| 64 |
+
return errors
|
| 65 |
+
|
| 66 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 67 |
+
return ReadFileInvocation(params)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class ReadFileInvocation(ToolInvocation):
|
| 71 |
+
def get_description(self) -> str:
|
| 72 |
+
return f"Read file: {self.params['path']}"
|
| 73 |
+
|
| 74 |
+
def get_affected_paths(self) -> list[str]:
|
| 75 |
+
return [self.params.get("path", "")]
|
| 76 |
+
|
| 77 |
+
def execute(self) -> ToolResult:
|
| 78 |
+
try:
|
| 79 |
+
path = _resolve_safe(self.params["path"])
|
| 80 |
+
if not path.exists():
|
| 81 |
+
return ToolResult(success=False, error=f"File not found: {self.params['path']}")
|
| 82 |
+
if not path.is_file():
|
| 83 |
+
return ToolResult(success=False, error=f"Not a file: {self.params['path']}")
|
| 84 |
+
|
| 85 |
+
offset = self.params.get("offset", 1) or 1
|
| 86 |
+
limit = self.params.get("limit", 500) or 500
|
| 87 |
+
|
| 88 |
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 89 |
+
total = len(lines)
|
| 90 |
+
start = max(0, offset - 1)
|
| 91 |
+
end = min(total, start + limit)
|
| 92 |
+
selected = lines[start:end]
|
| 93 |
+
|
| 94 |
+
# Add line numbers
|
| 95 |
+
numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
|
| 96 |
+
content = "\n".join(numbered)
|
| 97 |
+
truncated = end < total
|
| 98 |
+
|
| 99 |
+
meta = {"total_lines": total, "returned_lines": len(selected), "truncated": truncated}
|
| 100 |
+
if truncated:
|
| 101 |
+
meta["next_offset"] = end + 1
|
| 102 |
+
|
| 103 |
+
return ToolResult(success=True, output=content, metadata=meta)
|
| 104 |
+
except ValueError as e:
|
| 105 |
+
return ToolResult(success=False, error=str(e))
|
| 106 |
+
except Exception as e:
|
| 107 |
+
return ToolResult(success=False, error=f"Read error: {e}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ββ Write File βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
|
| 112 |
+
class WriteFileBuilder(ToolBuilder):
|
| 113 |
+
name = "write_file"
|
| 114 |
+
description = (
|
| 115 |
+
"Write content to a file. Creates parent directories automatically. "
|
| 116 |
+
"Overwrites existing file content entirely."
|
| 117 |
+
)
|
| 118 |
+
kind = ToolKind.WRITE
|
| 119 |
+
|
| 120 |
+
def get_schema(self) -> dict[str, Any]:
|
| 121 |
+
return {
|
| 122 |
+
"type": "object",
|
| 123 |
+
"properties": {
|
| 124 |
+
"path": {"type": "string", "description": "File path relative to workspace root"},
|
| 125 |
+
"content": {"type": "string", "description": "Full file content to write"},
|
| 126 |
+
},
|
| 127 |
+
"required": ["path", "content"],
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
def validate(self, params: dict[str, Any]) -> list[str]:
|
| 131 |
+
errors = []
|
| 132 |
+
if not params.get("path"):
|
| 133 |
+
errors.append("path is required")
|
| 134 |
+
if "content" not in params:
|
| 135 |
+
errors.append("content is required")
|
| 136 |
+
return errors
|
| 137 |
+
|
| 138 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 139 |
+
return WriteFileInvocation(params)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class WriteFileInvocation(ToolInvocation):
|
| 143 |
+
def get_description(self) -> str:
|
| 144 |
+
return f"Write file: {self.params['path']}"
|
| 145 |
+
|
| 146 |
+
def get_affected_paths(self) -> list[str]:
|
| 147 |
+
return [self.params.get("path", "")]
|
| 148 |
+
|
| 149 |
+
def execute(self) -> ToolResult:
|
| 150 |
+
try:
|
| 151 |
+
path = _resolve_safe(self.params["path"])
|
| 152 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 153 |
+
path.write_text(self.params["content"], encoding="utf-8")
|
| 154 |
+
line_count = self.params["content"].count("\n") + 1
|
| 155 |
+
return ToolResult(
|
| 156 |
+
success=True,
|
| 157 |
+
output=f"Written {line_count} lines to {self.params['path']}",
|
| 158 |
+
metadata={"path": str(path), "lines": line_count},
|
| 159 |
+
)
|
| 160 |
+
except ValueError as e:
|
| 161 |
+
return ToolResult(success=False, error=str(e))
|
| 162 |
+
except Exception as e:
|
| 163 |
+
return ToolResult(success=False, error=f"Write error: {e}")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ββ Edit File (Search & Replace) ββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
|
| 168 |
+
class EditFileBuilder(ToolBuilder):
|
| 169 |
+
name = "edit_file"
|
| 170 |
+
description = (
|
| 171 |
+
"Edit a file by finding exact old_string and replacing with new_string. "
|
| 172 |
+
"Claude Code pattern: exact match, not line-based diffs. "
|
| 173 |
+
"Set replace_all=true to replace every occurrence."
|
| 174 |
+
)
|
| 175 |
+
kind = ToolKind.EDIT
|
| 176 |
+
|
| 177 |
+
def get_schema(self) -> dict[str, Any]:
|
| 178 |
+
return {
|
| 179 |
+
"type": "object",
|
| 180 |
+
"properties": {
|
| 181 |
+
"path": {"type": "string", "description": "File path relative to workspace root"},
|
| 182 |
+
"old_string": {"type": "string", "description": "Exact text to find"},
|
| 183 |
+
"new_string": {"type": "string", "description": "Replacement text"},
|
| 184 |
+
"replace_all": {"type": "boolean", "description": "Replace all occurrences", "default": False},
|
| 185 |
+
},
|
| 186 |
+
"required": ["path", "old_string", "new_string"],
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
def validate(self, params: dict[str, Any]) -> list[str]:
|
| 190 |
+
errors = []
|
| 191 |
+
if not params.get("path"):
|
| 192 |
+
errors.append("path is required")
|
| 193 |
+
if not params.get("old_string"):
|
| 194 |
+
errors.append("old_string is required")
|
| 195 |
+
if "new_string" not in params:
|
| 196 |
+
errors.append("new_string is required")
|
| 197 |
+
if params.get("old_string") == params.get("new_string"):
|
| 198 |
+
errors.append("old_string and new_string must differ")
|
| 199 |
+
return errors
|
| 200 |
+
|
| 201 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 202 |
+
return EditFileInvocation(params)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class EditFileInvocation(ToolInvocation):
|
| 206 |
+
def get_description(self) -> str:
|
| 207 |
+
p = self.params["path"]
|
| 208 |
+
old = self.params["old_string"][:50].replace("\n", "\\n")
|
| 209 |
+
return f"Edit {p}: '{old}...'"
|
| 210 |
+
|
| 211 |
+
def get_affected_paths(self) -> list[str]:
|
| 212 |
+
return [self.params.get("path", "")]
|
| 213 |
+
|
| 214 |
+
def execute(self) -> ToolResult:
|
| 215 |
+
try:
|
| 216 |
+
path = _resolve_safe(self.params["path"])
|
| 217 |
+
if not path.exists():
|
| 218 |
+
return ToolResult(success=False, error=f"File not found: {self.params['path']}")
|
| 219 |
+
|
| 220 |
+
content = path.read_text(encoding="utf-8")
|
| 221 |
+
old = self.params["old_string"]
|
| 222 |
+
new = self.params["new_string"]
|
| 223 |
+
replace_all = self.params.get("replace_all", False)
|
| 224 |
+
|
| 225 |
+
if old not in content:
|
| 226 |
+
# Fuzzy hint
|
| 227 |
+
return ToolResult(
|
| 228 |
+
success=False,
|
| 229 |
+
error=f"old_string not found in {self.params['path']}. "
|
| 230 |
+
f"Make sure the text matches the file exactly (including whitespace).",
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
count = content.count(old)
|
| 234 |
+
if replace_all:
|
| 235 |
+
new_content = content.replace(old, new)
|
| 236 |
+
else:
|
| 237 |
+
if count > 1:
|
| 238 |
+
return ToolResult(
|
| 239 |
+
success=False,
|
| 240 |
+
error=f"Found {count} occurrences. Use replace_all=true to replace all, "
|
| 241 |
+
f"or make old_string more specific.",
|
| 242 |
+
)
|
| 243 |
+
new_content = content.replace(old, new, 1)
|
| 244 |
+
|
| 245 |
+
path.write_text(new_content, encoding="utf-8")
|
| 246 |
+
return ToolResult(
|
| 247 |
+
success=True,
|
| 248 |
+
output=f"Replaced {count if replace_all else 1} occurrence(s) in {self.params['path']}",
|
| 249 |
+
metadata={"path": str(path), "replacements": count if replace_all else 1},
|
| 250 |
+
)
|
| 251 |
+
except ValueError as e:
|
| 252 |
+
return ToolResult(success=False, error=str(e))
|
| 253 |
+
except Exception as e:
|
| 254 |
+
return ToolResult(success=False, error=f"Edit error: {e}")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# ββ Glob βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 258 |
+
|
| 259 |
+
class GlobBuilder(ToolBuilder):
|
| 260 |
+
name = "glob"
|
| 261 |
+
description = (
|
| 262 |
+
"Find files matching a glob pattern (e.g., '**/*.py', 'src/**/*.ts'). "
|
| 263 |
+
"Searches recursively within the workspace."
|
| 264 |
+
)
|
| 265 |
+
kind = ToolKind.READ
|
| 266 |
+
is_read_only = True
|
| 267 |
+
|
| 268 |
+
def get_schema(self) -> dict[str, Any]:
|
| 269 |
+
return {
|
| 270 |
+
"type": "object",
|
| 271 |
+
"properties": {
|
| 272 |
+
"pattern": {"type": "string", "description": "Glob pattern (e.g., '**/*.py')"},
|
| 273 |
+
"path": {"type": "string", "description": "Subdirectory to search in", "default": "."},
|
| 274 |
+
},
|
| 275 |
+
"required": ["pattern"],
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 279 |
+
return GlobInvocation(params)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class GlobInvocation(ToolInvocation):
|
| 283 |
+
def get_description(self) -> str:
|
| 284 |
+
return f"Glob: {self.params['pattern']}"
|
| 285 |
+
|
| 286 |
+
def execute(self) -> ToolResult:
|
| 287 |
+
try:
|
| 288 |
+
base = _resolve_safe(self.params.get("path", "."))
|
| 289 |
+
pattern = self.params["pattern"]
|
| 290 |
+
if not base.exists():
|
| 291 |
+
return ToolResult(success=True, output="Directory not found", metadata={"matches": []})
|
| 292 |
+
|
| 293 |
+
matches = []
|
| 294 |
+
for root, dirs, files in os.walk(base):
|
| 295 |
+
# Skip hidden and common heavy dirs
|
| 296 |
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in
|
| 297 |
+
("node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".git")]
|
| 298 |
+
for f in files:
|
| 299 |
+
full = os.path.join(root, f)
|
| 300 |
+
rel = os.path.relpath(full, WORKSPACE)
|
| 301 |
+
if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(f, pattern):
|
| 302 |
+
matches.append(rel)
|
| 303 |
+
|
| 304 |
+
matches.sort()
|
| 305 |
+
output = "\n".join(matches) if matches else "No matches found."
|
| 306 |
+
return ToolResult(success=True, output=output, metadata={"matches": matches, "count": len(matches)})
|
| 307 |
+
except ValueError as e:
|
| 308 |
+
return ToolResult(success=False, error=str(e))
|
| 309 |
+
except Exception as e:
|
| 310 |
+
return ToolResult(success=False, error=f"Glob error: {e}")
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ββ Grep βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 314 |
+
|
| 315 |
+
class GrepBuilder(ToolBuilder):
|
| 316 |
+
name = "grep"
|
| 317 |
+
description = (
|
| 318 |
+
"Search file contents using regex. Returns matching lines with file paths and line numbers. "
|
| 319 |
+
"Like ripgrep but simpler."
|
| 320 |
+
)
|
| 321 |
+
kind = ToolKind.READ
|
| 322 |
+
is_read_only = True
|
| 323 |
+
|
| 324 |
+
def get_schema(self) -> dict[str, Any]:
|
| 325 |
+
return {
|
| 326 |
+
"type": "object",
|
| 327 |
+
"properties": {
|
| 328 |
+
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
| 329 |
+
"path": {"type": "string", "description": "Subdirectory to search in", "default": "."},
|
| 330 |
+
"include": {"type": "string", "description": "File glob to include (e.g., '*.py')"},
|
| 331 |
+
"ignore_case": {"type": "boolean", "description": "Case insensitive", "default": False},
|
| 332 |
+
"max_results": {"type": "integer", "description": "Max matches to return", "default": 50},
|
| 333 |
+
},
|
| 334 |
+
"required": ["pattern"],
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 338 |
+
return GrepInvocation(params)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
class GrepInvocation(ToolInvocation):
|
| 342 |
+
def get_description(self) -> str:
|
| 343 |
+
return f"Grep: {self.params['pattern']}"
|
| 344 |
+
|
| 345 |
+
def execute(self) -> ToolResult:
|
| 346 |
+
try:
|
| 347 |
+
base = _resolve_safe(self.params.get("path", "."))
|
| 348 |
+
pattern = self.params["pattern"]
|
| 349 |
+
include = self.params.get("include", "*")
|
| 350 |
+
ignore_case = self.params.get("ignore_case", False)
|
| 351 |
+
max_results = self.params.get("max_results", 50) or 50
|
| 352 |
+
|
| 353 |
+
flags = re.IGNORECASE if ignore_case else 0
|
| 354 |
+
regex = re.compile(pattern, flags)
|
| 355 |
+
results = []
|
| 356 |
+
count = 0
|
| 357 |
+
|
| 358 |
+
for root, dirs, files in os.walk(base):
|
| 359 |
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in
|
| 360 |
+
("node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".git")]
|
| 361 |
+
for f in files:
|
| 362 |
+
if not fnmatch.fnmatch(f, include):
|
| 363 |
+
continue
|
| 364 |
+
try:
|
| 365 |
+
filepath = os.path.join(root, f)
|
| 366 |
+
rel = os.path.relpath(filepath, WORKSPACE)
|
| 367 |
+
lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 368 |
+
for i, line in enumerate(lines, 1):
|
| 369 |
+
if regex.search(line):
|
| 370 |
+
results.append(f"{rel}:{i}: {line.strip()}")
|
| 371 |
+
count += 1
|
| 372 |
+
if count >= max_results:
|
| 373 |
+
output = "\n".join(results)
|
| 374 |
+
output += f"\n\n... truncated at {max_results} matches"
|
| 375 |
+
return ToolResult(
|
| 376 |
+
success=True, output=output,
|
| 377 |
+
metadata={"matches": results, "count": count, "truncated": True},
|
| 378 |
+
)
|
| 379 |
+
except (OSError, UnicodeDecodeError):
|
| 380 |
+
continue
|
| 381 |
+
|
| 382 |
+
output = "\n".join(results) if results else "No matches found."
|
| 383 |
+
return ToolResult(
|
| 384 |
+
success=True, output=output,
|
| 385 |
+
metadata={"matches": results, "count": count, "truncated": False},
|
| 386 |
+
)
|
| 387 |
+
except ValueError as e:
|
| 388 |
+
return ToolResult(success=False, error=str(e))
|
| 389 |
+
except re.error as e:
|
| 390 |
+
return ToolResult(success=False, error=f"Invalid regex: {e}")
|
| 391 |
+
except Exception as e:
|
| 392 |
+
return ToolResult(success=False, error=f"Grep error: {e}")
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ββ List Directory βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
|
| 397 |
+
class ListDirBuilder(ToolBuilder):
|
| 398 |
+
name = "list_dir"
|
| 399 |
+
description = "List files and directories. Returns sorted listing with type indicators."
|
| 400 |
+
kind = ToolKind.READ
|
| 401 |
+
is_read_only = True
|
| 402 |
+
|
| 403 |
+
def get_schema(self) -> dict[str, Any]:
|
| 404 |
+
return {
|
| 405 |
+
"type": "object",
|
| 406 |
+
"properties": {
|
| 407 |
+
"path": {"type": "string", "description": "Directory path (default: workspace root)", "default": "."},
|
| 408 |
+
},
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 412 |
+
return ListDirInvocation(params)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
class ListDirInvocation(ToolInvocation):
|
| 416 |
+
def get_description(self) -> str:
|
| 417 |
+
return f"List dir: {self.params.get('path', '.')}"
|
| 418 |
+
|
| 419 |
+
def execute(self) -> ToolResult:
|
| 420 |
+
try:
|
| 421 |
+
path = _resolve_safe(self.params.get("path", "."))
|
| 422 |
+
if not path.exists():
|
| 423 |
+
return ToolResult(success=False, error=f"Directory not found: {self.params.get('path', '.')}")
|
| 424 |
+
if not path.is_dir():
|
| 425 |
+
return ToolResult(success=False, error=f"Not a directory: {self.params.get('path', '.')}")
|
| 426 |
+
|
| 427 |
+
entries = []
|
| 428 |
+
for item in sorted(path.iterdir()):
|
| 429 |
+
if item.name.startswith("."):
|
| 430 |
+
continue
|
| 431 |
+
if item.is_dir():
|
| 432 |
+
entries.append(f" [DIR] {item.name}/")
|
| 433 |
+
else:
|
| 434 |
+
size = item.stat().st_size
|
| 435 |
+
if size > 1024 * 1024:
|
| 436 |
+
size_str = f"{size / 1024 / 1024:.1f}MB"
|
| 437 |
+
elif size > 1024:
|
| 438 |
+
size_str = f"{size / 1024:.1f}KB"
|
| 439 |
+
else:
|
| 440 |
+
size_str = f"{size}B"
|
| 441 |
+
entries.append(f" [FILE] {item.name} ({size_str})")
|
| 442 |
+
|
| 443 |
+
output = "\n".join(entries) if entries else "(empty directory)"
|
| 444 |
+
return ToolResult(success=True, output=output, metadata={"entries": len(entries)})
|
| 445 |
+
except ValueError as e:
|
| 446 |
+
return ToolResult(success=False, error=str(e))
|
| 447 |
+
except Exception as e:
|
| 448 |
+
return ToolResult(success=False, error=f"List error: {e}")
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# ββ Workspace Snapshot βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 452 |
+
|
| 453 |
+
class SnapshotBuilder(ToolBuilder):
|
| 454 |
+
name = "snapshot_workspace"
|
| 455 |
+
description = "Get a snapshot of all workspace files and their contents. Useful for getting full project state."
|
| 456 |
+
kind = ToolKind.READ
|
| 457 |
+
is_read_only = True
|
| 458 |
+
|
| 459 |
+
def get_schema(self) -> dict[str, Any]:
|
| 460 |
+
return {
|
| 461 |
+
"type": "object",
|
| 462 |
+
"properties": {},
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 466 |
+
return SnapshotInvocation(params)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
class SnapshotInvocation(ToolInvocation):
|
| 470 |
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
| 471 |
+
".sonicoder"}
|
| 472 |
+
|
| 473 |
+
def execute(self) -> ToolResult:
|
| 474 |
+
try:
|
| 475 |
+
files = {}
|
| 476 |
+
for root, dirs, filenames in os.walk(WORKSPACE):
|
| 477 |
+
dirs[:] = [d for d in dirs if d not in self.SKIP_DIRS and not d.startswith(".")]
|
| 478 |
+
for f in filenames:
|
| 479 |
+
if f.startswith("."):
|
| 480 |
+
continue
|
| 481 |
+
fp = os.path.join(root, f)
|
| 482 |
+
rel = os.path.relpath(fp, WORKSPACE)
|
| 483 |
+
try:
|
| 484 |
+
files[rel] = fp if os.path.getsize(fp) > 500000 else \
|
| 485 |
+
Path(fp).read_text(encoding="utf-8", errors="replace")
|
| 486 |
+
except (OSError, UnicodeDecodeError):
|
| 487 |
+
files[rel] = f"[binary file, {os.path.getsize(fp)} bytes]"
|
| 488 |
+
|
| 489 |
+
output = "\n".join(f" {k}" for k in sorted(files.keys()))
|
| 490 |
+
return ToolResult(
|
| 491 |
+
success=True, output=output,
|
| 492 |
+
metadata={"files": files, "count": len(files)},
|
| 493 |
+
)
|
| 494 |
+
except Exception as e:
|
| 495 |
+
return ToolResult(success=False, error=f"Snapshot error: {e}")
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
# ββ Register All βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 499 |
+
|
| 500 |
+
BUILTIN_TOOLS = [
|
| 501 |
+
ReadFileBuilder(),
|
| 502 |
+
WriteFileBuilder(),
|
| 503 |
+
EditFileBuilder(),
|
| 504 |
+
GlobBuilder(),
|
| 505 |
+
GrepBuilder(),
|
| 506 |
+
ListDirBuilder(),
|
| 507 |
+
SnapshotBuilder(),
|
| 508 |
+
]
|
sonicoder/tools/todos.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Todo management tool β per-session task tracking.
|
| 2 |
+
|
| 3 |
+
Inspired by Claude Code's TodoWrite/TaskCreate.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import threading
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
|
| 12 |
+
|
| 13 |
+
# ββ In-Memory Store ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
|
| 15 |
+
_store: dict[str, list[dict]] = {}
|
| 16 |
+
_lock = threading.Lock()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _get_todos(session_id: str) -> list[dict]:
|
| 20 |
+
with _lock:
|
| 21 |
+
return _store.setdefault(session_id, [])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ββ Todo Tool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
class TodoBuilder(ToolBuilder):
|
| 27 |
+
name = "todo"
|
| 28 |
+
description = (
|
| 29 |
+
"Manage a task todo list. Actions: 'read' (list all), 'write' (create), "
|
| 30 |
+
"'update' (change status/content), 'clear' (remove all). "
|
| 31 |
+
"Each todo has: id, content, status (pending/in_progress/completed)."
|
| 32 |
+
)
|
| 33 |
+
kind = ToolKind.OTHER
|
| 34 |
+
|
| 35 |
+
def get_schema(self) -> dict[str, Any]:
|
| 36 |
+
return {
|
| 37 |
+
"type": "object",
|
| 38 |
+
"properties": {
|
| 39 |
+
"action": {
|
| 40 |
+
"type": "string",
|
| 41 |
+
"enum": ["read", "write", "update", "clear"],
|
| 42 |
+
"description": "Action to perform",
|
| 43 |
+
},
|
| 44 |
+
"id": {"type": "string", "description": "Todo ID (for update)"},
|
| 45 |
+
"content": {"type": "string", "description": "Todo content (for write/update)"},
|
| 46 |
+
"status": {"type": "string", "description": "Status: pending/in_progress/completed"},
|
| 47 |
+
},
|
| 48 |
+
"required": ["action"],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 52 |
+
return TodoInvocation(params, session_id="")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class TodoInvocation(ToolInvocation):
|
| 56 |
+
def __init__(self, params: dict[str, Any], session_id: str = ""):
|
| 57 |
+
super().__init__(params)
|
| 58 |
+
self.session_id = session_id
|
| 59 |
+
|
| 60 |
+
def get_description(self) -> str:
|
| 61 |
+
return f"Todo: {self.params['action']}"
|
| 62 |
+
|
| 63 |
+
def execute(self) -> ToolResult:
|
| 64 |
+
action = self.params["action"]
|
| 65 |
+
todos = _get_todos(self.session_id)
|
| 66 |
+
|
| 67 |
+
if action == "read":
|
| 68 |
+
if not todos:
|
| 69 |
+
return ToolResult(success=True, output="No todos.")
|
| 70 |
+
lines = []
|
| 71 |
+
for t in todos:
|
| 72 |
+
status_icon = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[ ]")
|
| 73 |
+
lines.append(f" {status_icon} {t['id']}: {t['content']}")
|
| 74 |
+
return ToolResult(success=True, output="\n".join(lines), metadata={"todos": todos})
|
| 75 |
+
|
| 76 |
+
elif action == "write":
|
| 77 |
+
content = self.params.get("content", "")
|
| 78 |
+
if not content:
|
| 79 |
+
return ToolResult(success=False, error="content is required for write action")
|
| 80 |
+
new_id = str(len(todos) + 1)
|
| 81 |
+
todo = {"id": new_id, "content": content, "status": "pending"}
|
| 82 |
+
todos.append(todo)
|
| 83 |
+
return ToolResult(success=True, output=f"Added todo {new_id}: {content}", metadata={"todo": todo})
|
| 84 |
+
|
| 85 |
+
elif action == "update":
|
| 86 |
+
tid = self.params.get("id", "")
|
| 87 |
+
if not tid:
|
| 88 |
+
return ToolResult(success=False, error="id is required for update action")
|
| 89 |
+
for t in todos:
|
| 90 |
+
if t["id"] == tid:
|
| 91 |
+
if "content" in self.params and self.params["content"]:
|
| 92 |
+
t["content"] = self.params["content"]
|
| 93 |
+
if "status" in self.params and self.params["status"]:
|
| 94 |
+
if self.params["status"] not in ("pending", "in_progress", "completed"):
|
| 95 |
+
return ToolResult(success=False, error="Invalid status")
|
| 96 |
+
t["status"] = self.params["status"]
|
| 97 |
+
return ToolResult(success=True, output=f"Updated todo {tid}", metadata={"todo": t})
|
| 98 |
+
return ToolResult(success=False, error=f"Todo {tid} not found")
|
| 99 |
+
|
| 100 |
+
elif action == "clear":
|
| 101 |
+
todos.clear()
|
| 102 |
+
return ToolResult(success=True, output="All todos cleared.")
|
| 103 |
+
|
| 104 |
+
return ToolResult(success=False, error=f"Unknown action: {action}")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ββ Register βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
|
| 109 |
+
BUILTIN_TOOLS = [TodoBuilder()]
|
sonicoder/tools/web.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Web tools β search and fetch.
|
| 2 |
+
|
| 3 |
+
Inspired by Claude Code's WebSearch/WebFetch and Gemini CLI's google_web_search/web_fetch.
|
| 4 |
+
No API keys needed β uses HTML scraping like original SoniCoder.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from typing import Any
|
| 11 |
+
from urllib.parse import quote_plus, urljoin
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
from bs4 import BeautifulSoup
|
| 15 |
+
|
| 16 |
+
from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
|
| 17 |
+
|
| 18 |
+
HEADERS = {
|
| 19 |
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
| 20 |
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ββ Web Search βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
class WebSearchBuilder(ToolBuilder):
|
| 27 |
+
name = "web_search"
|
| 28 |
+
description = (
|
| 29 |
+
"Search the web using DuckDuckGo. No API key needed. "
|
| 30 |
+
"Returns numbered results with titles, URLs, and snippets."
|
| 31 |
+
)
|
| 32 |
+
kind = ToolKind.OTHER
|
| 33 |
+
is_read_only = True
|
| 34 |
+
|
| 35 |
+
def get_schema(self) -> dict[str, Any]:
|
| 36 |
+
return {
|
| 37 |
+
"type": "object",
|
| 38 |
+
"properties": {
|
| 39 |
+
"query": {"type": "string", "description": "Search query"},
|
| 40 |
+
"max_results": {"type": "integer", "description": "Max results (default 8)", "default": 8},
|
| 41 |
+
},
|
| 42 |
+
"required": ["query"],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 46 |
+
return WebSearchInvocation(params)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class WebSearchInvocation(ToolInvocation):
|
| 50 |
+
def get_description(self) -> str:
|
| 51 |
+
return f"Web search: {self.params['query'][:60]}"
|
| 52 |
+
|
| 53 |
+
def execute(self) -> ToolResult:
|
| 54 |
+
query = self.params["query"]
|
| 55 |
+
max_results = self.params.get("max_results", 8) or 8
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
# DuckDuckGo HTML search
|
| 59 |
+
url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
|
| 60 |
+
resp = requests.get(url, headers=HEADERS, timeout=15)
|
| 61 |
+
resp.raise_for_status()
|
| 62 |
+
|
| 63 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 64 |
+
results = []
|
| 65 |
+
|
| 66 |
+
for result in soup.select(".result"):
|
| 67 |
+
title_el = result.select_one(".result__title a, .result__a")
|
| 68 |
+
snippet_el = result.select_one(".result__snippet")
|
| 69 |
+
if title_el:
|
| 70 |
+
title = title_el.get_text(strip=True)
|
| 71 |
+
href = title_el.get("href", "")
|
| 72 |
+
# Clean DuckDuckGo redirect URL
|
| 73 |
+
if "uddg=" in href:
|
| 74 |
+
import urllib.parse
|
| 75 |
+
parsed = urllib.parse.parse_qs(urllib.parse.urlparse(href).query)
|
| 76 |
+
href = parsed.get("uddg", [href])[0]
|
| 77 |
+
snippet = snippet_el.get_text(strip=True) if snippet_el else ""
|
| 78 |
+
results.append(f"{len(results) + 1}. {title}\n {href}\n {snippet}")
|
| 79 |
+
if len(results) >= max_results:
|
| 80 |
+
break
|
| 81 |
+
|
| 82 |
+
if not results:
|
| 83 |
+
return ToolResult(success=True, output="No results found.", metadata={"results": []})
|
| 84 |
+
|
| 85 |
+
output = "\n\n".join(results)
|
| 86 |
+
return ToolResult(
|
| 87 |
+
success=True, output=output,
|
| 88 |
+
metadata={"results": results, "count": len(results)},
|
| 89 |
+
)
|
| 90 |
+
except requests.RequestException as e:
|
| 91 |
+
return ToolResult(success=False, error=f"Search failed: {e}")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
return ToolResult(success=False, error=f"Search error: {e}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ββ Web Fetch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
|
| 98 |
+
class WebFetchBuilder(ToolBuilder):
|
| 99 |
+
name = "web_fetch"
|
| 100 |
+
description = (
|
| 101 |
+
"Fetch and extract text content from a URL. "
|
| 102 |
+
"Returns the main text content (strips HTML tags, scripts, styles)."
|
| 103 |
+
)
|
| 104 |
+
kind = ToolKind.OTHER
|
| 105 |
+
is_read_only = True
|
| 106 |
+
|
| 107 |
+
def get_schema(self) -> dict[str, Any]:
|
| 108 |
+
return {
|
| 109 |
+
"type": "object",
|
| 110 |
+
"properties": {
|
| 111 |
+
"url": {"type": "string", "description": "URL to fetch"},
|
| 112 |
+
},
|
| 113 |
+
"required": ["url"],
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
|
| 117 |
+
return WebFetchInvocation(params)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class WebFetchInvocation(ToolInvocation):
|
| 121 |
+
MAX_CONTENT = 30000
|
| 122 |
+
|
| 123 |
+
def get_description(self) -> str:
|
| 124 |
+
return f"Fetch: {self.params['url'][:80]}"
|
| 125 |
+
|
| 126 |
+
def execute(self) -> ToolResult:
|
| 127 |
+
url = self.params["url"]
|
| 128 |
+
if not url.startswith(("http://", "https://")):
|
| 129 |
+
url = "https://" + url
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
resp = requests.get(url, headers=HEADERS, timeout=20)
|
| 133 |
+
resp.raise_for_status()
|
| 134 |
+
|
| 135 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 136 |
+
|
| 137 |
+
# Remove scripts, styles, nav, footer
|
| 138 |
+
for tag in soup.select("script, style, nav, footer, header, aside, iframe"):
|
| 139 |
+
tag.decompose()
|
| 140 |
+
|
| 141 |
+
# Extract text
|
| 142 |
+
text = soup.get_text(separator="\n", strip=True)
|
| 143 |
+
|
| 144 |
+
# Clean up whitespace
|
| 145 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 146 |
+
text = "\n".join(lines)
|
| 147 |
+
|
| 148 |
+
if len(text) > self.MAX_CONTENT:
|
| 149 |
+
text = text[:self.MAX_CONTENT] + f"\n... [truncated at {self.MAX_CONTENT} chars]"
|
| 150 |
+
|
| 151 |
+
return ToolResult(
|
| 152 |
+
success=True, output=text,
|
| 153 |
+
metadata={"url": url, "chars": len(text), "title": soup.title.string if soup.title else ""},
|
| 154 |
+
)
|
| 155 |
+
except requests.RequestException as e:
|
| 156 |
+
return ToolResult(success=False, error=f"Fetch failed: {e}")
|
| 157 |
+
except Exception as e:
|
| 158 |
+
return ToolResult(success=False, error=f"Fetch error: {e}")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ββ Register βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
+
|
| 163 |
+
BUILTIN_TOOLS = [WebSearchBuilder(), WebFetchBuilder()]
|
static/index.html
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>SoniCoder v2 β AI Code Agent</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg-primary: #0f0f13;
|
| 10 |
+
--bg-secondary: #16161d;
|
| 11 |
+
--bg-tertiary: #1e1e2a;
|
| 12 |
+
--bg-hover: #252535;
|
| 13 |
+
--text-primary: #e4e4f0;
|
| 14 |
+
--text-secondary: #9393a8;
|
| 15 |
+
--text-muted: #6b6b80;
|
| 16 |
+
--accent: #7c6ef0;
|
| 17 |
+
--accent-hover: #6b5de0;
|
| 18 |
+
--accent-light: rgba(124,110,240,0.12);
|
| 19 |
+
--success: #4ade80;
|
| 20 |
+
--warning: #fbbf24;
|
| 21 |
+
--error: #f87171;
|
| 22 |
+
--info: #60a5fa;
|
| 23 |
+
--border: #2a2a3a;
|
| 24 |
+
--code-bg: #1a1a28;
|
| 25 |
+
--radius: 12px;
|
| 26 |
+
--radius-sm: 8px;
|
| 27 |
+
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
| 28 |
+
}
|
| 29 |
+
* { margin:0; padding:0; box-sizing:border-box; }
|
| 30 |
+
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:var(--bg-primary); color:var(--text-primary); height:100vh; overflow:hidden; }
|
| 31 |
+
|
| 32 |
+
/* Layout */
|
| 33 |
+
.app { display:flex; height:100vh; }
|
| 34 |
+
.sidebar { width:280px; background:var(--bg-secondary); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
|
| 35 |
+
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
|
| 36 |
+
.workspace-panel { width:300px; background:var(--bg-secondary); border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; transition:width 0.3s; overflow:hidden; }
|
| 37 |
+
.workspace-panel.collapsed { width:0; border-left:none; }
|
| 38 |
+
|
| 39 |
+
/* Sidebar */
|
| 40 |
+
.sidebar-header { padding:20px; border-bottom:1px solid var(--border); }
|
| 41 |
+
.logo { font-size:20px; font-weight:700; background:linear-gradient(135deg, #7c6ef0, #a78bfa, #60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
| 42 |
+
.logo-sub { font-size:11px; color:var(--text-muted); margin-top:2px; letter-spacing:1px; text-transform:uppercase; }
|
| 43 |
+
.model-selector { padding:16px 20px; border-bottom:1px solid var(--border); }
|
| 44 |
+
.model-selector label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; display:block; margin-bottom:8px; }
|
| 45 |
+
.model-selector select { width:100%; padding:8px 12px; background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; outline:none; }
|
| 46 |
+
.model-selector select:focus { border-color:var(--accent); }
|
| 47 |
+
.model-status { padding:8px 20px; border-bottom:1px solid var(--border); }
|
| 48 |
+
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
|
| 49 |
+
.status-dot.ready { background:var(--success); box-shadow:0 0 6px var(--success); }
|
| 50 |
+
.status-dot.loading { background:var(--warning); animation:pulse 1.5s infinite; }
|
| 51 |
+
.status-dot.error { background:var(--error); }
|
| 52 |
+
.status-dot.unknown { background:var(--text-muted); }
|
| 53 |
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
| 54 |
+
.status-text { font-size:12px; color:var(--text-secondary); }
|
| 55 |
+
|
| 56 |
+
.sidebar-section { padding:16px 20px; }
|
| 57 |
+
.sidebar-section h3 { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:12px; }
|
| 58 |
+
.sidebar-info { font-size:12px; color:var(--text-secondary); line-height:1.6; }
|
| 59 |
+
.sidebar-info code { background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; font-size:11px; color:var(--accent); }
|
| 60 |
+
|
| 61 |
+
.new-chat-btn { margin:16px 20px; padding:10px; background:var(--accent); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; }
|
| 62 |
+
.new-chat-btn:hover { background:var(--accent-hover); transform:translateY(-1px); }
|
| 63 |
+
|
| 64 |
+
/* Chat Area */
|
| 65 |
+
.chat-header { padding:16px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
|
| 66 |
+
.chat-header h2 { font-size:15px; font-weight:600; }
|
| 67 |
+
.header-actions { display:flex; gap:8px; }
|
| 68 |
+
.header-btn { padding:6px 12px; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:12px; cursor:pointer; transition:all 0.2s; }
|
| 69 |
+
.header-btn:hover { background:var(--bg-hover); color:var(--text-primary); }
|
| 70 |
+
|
| 71 |
+
.chat-messages { flex:1; overflow-y:auto; padding:24px; scroll-behavior:smooth; }
|
| 72 |
+
.chat-messages::-webkit-scrollbar { width:6px; }
|
| 73 |
+
.chat-messages::-webkit-scrollbar-track { background:transparent; }
|
| 74 |
+
.chat-messages::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
|
| 75 |
+
|
| 76 |
+
.message { margin-bottom:24px; animation:fadeIn 0.3s ease; }
|
| 77 |
+
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
|
| 78 |
+
.message-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
|
| 79 |
+
.message-avatar { width:28px; height:28px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:700; }
|
| 80 |
+
.message-avatar.user { background:var(--accent); color:white; }
|
| 81 |
+
.message-avatar.agent { background:var(--bg-tertiary); color:var(--accent); border:1px solid var(--border); }
|
| 82 |
+
.message-role { font-size:13px; font-weight:600; }
|
| 83 |
+
.message-time { font-size:11px; color:var(--text-muted); }
|
| 84 |
+
|
| 85 |
+
.message-body { padding-left:36px; line-height:1.7; font-size:14px; color:var(--text-primary); }
|
| 86 |
+
.message-body p { margin-bottom:8px; }
|
| 87 |
+
.message-body code { background:var(--code-bg); padding:2px 6px; border-radius:4px; font-size:13px; font-family:'JetBrains Mono','Fira Code',monospace; }
|
| 88 |
+
.message-body pre { background:var(--code-bg); padding:16px; border-radius:var(--radius-sm); overflow-x:auto; margin:12px 0; border:1px solid var(--border); }
|
| 89 |
+
.message-body pre code { background:none; padding:0; font-size:13px; line-height:1.5; }
|
| 90 |
+
.message-body ul, .message-body ol { padding-left:20px; margin:8px 0; }
|
| 91 |
+
.message-body li { margin:4px 0; }
|
| 92 |
+
|
| 93 |
+
/* Tool Calls */
|
| 94 |
+
.tool-call { background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius-sm); margin:12px 0; padding:12px 16px; }
|
| 95 |
+
.tool-call-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
|
| 96 |
+
.tool-icon { font-size:14px; }
|
| 97 |
+
.tool-name { font-size:13px; font-weight:600; color:var(--accent); }
|
| 98 |
+
.tool-status { font-size:11px; padding:2px 8px; border-radius:10px; font-weight:600; }
|
| 99 |
+
.tool-status.success { background:rgba(74,222,128,0.12); color:var(--success); }
|
| 100 |
+
.tool-status.error { background:rgba(248,113,113,0.12); color:var(--error); }
|
| 101 |
+
.tool-status.running { background:rgba(251,191,36,0.12); color:var(--warning); }
|
| 102 |
+
.tool-args { font-size:12px; color:var(--text-muted); margin-bottom:8px; }
|
| 103 |
+
.tool-result { font-size:12px; background:var(--bg-primary); border-radius:6px; padding:8px 12px; max-height:300px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; font-family:'JetBrains Mono',monospace; line-height:1.5; color:var(--text-secondary); }
|
| 104 |
+
.tool-result::-webkit-scrollbar { width:4px; }
|
| 105 |
+
.tool-result::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
|
| 106 |
+
|
| 107 |
+
/* Iteration Badge */
|
| 108 |
+
.iteration-badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 10px; border-radius:10px; margin-bottom:8px; }
|
| 109 |
+
|
| 110 |
+
/* Input Area */
|
| 111 |
+
.chat-input { padding:16px 24px 24px; border-top:1px solid var(--border); }
|
| 112 |
+
.input-wrapper { display:flex; align-items:flex-end; gap:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; transition:border-color 0.2s; }
|
| 113 |
+
.input-wrapper:focus-within { border-color:var(--accent); }
|
| 114 |
+
.input-wrapper textarea { flex:1; background:none; border:none; outline:none; color:var(--text-primary); font-size:14px; font-family:inherit; resize:none; max-height:200px; line-height:1.5; }
|
| 115 |
+
.input-wrapper textarea::placeholder { color:var(--text-muted); }
|
| 116 |
+
.send-btn { padding:8px 20px; background:var(--accent); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; white-space:nowrap; }
|
| 117 |
+
.send-btn:hover { background:var(--accent-hover); }
|
| 118 |
+
.send-btn:disabled { opacity:0.5; cursor:not-allowed; }
|
| 119 |
+
.stop-btn { padding:8px 20px; background:var(--error); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; }
|
| 120 |
+
.input-hint { font-size:11px; color:var(--text-muted); margin-top:8px; text-align:center; }
|
| 121 |
+
|
| 122 |
+
/* Workspace Panel */
|
| 123 |
+
.ws-header { padding:16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
|
| 124 |
+
.ws-header h3 { font-size:13px; font-weight:600; }
|
| 125 |
+
.ws-tree { flex:1; overflow-y:auto; padding:12px 16px; font-family:'JetBrains Mono',monospace; font-size:12px; line-height:1.8; color:var(--text-secondary); white-space:pre; }
|
| 126 |
+
.ws-tree::-webkit-scrollbar { width:4px; }
|
| 127 |
+
.ws-tree::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
|
| 128 |
+
|
| 129 |
+
/* Typing indicator */
|
| 130 |
+
.typing-indicator { display:flex; gap:4px; padding:8px 0; }
|
| 131 |
+
.typing-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); animation:typingBounce 1.4s infinite; }
|
| 132 |
+
.typing-dot:nth-child(2) { animation-delay:0.2s; }
|
| 133 |
+
.typing-dot:nth-child(3) { animation-delay:0.4s; }
|
| 134 |
+
@keyframes typingBounce { 0%,60%,100%{transform:translateY(0)} 30%{transform:translateY(-6px)} }
|
| 135 |
+
|
| 136 |
+
/* Example prompts */
|
| 137 |
+
.examples { padding:16px 20px; }
|
| 138 |
+
.example-btn { display:block; width:100%; text-align:left; padding:10px 12px; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; line-height:1.4; }
|
| 139 |
+
.example-btn:hover { background:var(--bg-hover); color:var(--text-primary); border-color:var(--accent); }
|
| 140 |
+
|
| 141 |
+
/* Responsive */
|
| 142 |
+
@media (max-width:900px) {
|
| 143 |
+
.sidebar { display:none; }
|
| 144 |
+
.workspace-panel { display:none; }
|
| 145 |
+
}
|
| 146 |
+
</style>
|
| 147 |
+
</head>
|
| 148 |
+
<body>
|
| 149 |
+
<div class="app">
|
| 150 |
+
<!-- Sidebar -->
|
| 151 |
+
<aside class="sidebar">
|
| 152 |
+
<div class="sidebar-header">
|
| 153 |
+
<div class="logo">SoniCoder v2</div>
|
| 154 |
+
<div class="logo-sub">AI Code Agent</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<div class="model-selector">
|
| 158 |
+
<label>Model</label>
|
| 159 |
+
<select id="modelSelect"></select>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div class="model-status">
|
| 163 |
+
<span class="status-dot unknown" id="statusDot"></span>
|
| 164 |
+
<span class="status-text" id="statusText">Initializing...</span>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<button class="new-chat-btn" onclick="newChat()">+ New Chat</button>
|
| 168 |
+
|
| 169 |
+
<div class="sidebar-section">
|
| 170 |
+
<h3>Quick Start</h3>
|
| 171 |
+
<div class="sidebar-info">
|
| 172 |
+
Ask me to build anything β a web app, API, script, or full-stack project. I'll create the files and verify they work.
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div class="examples" id="examplesPanel">
|
| 177 |
+
<div class="example-btn" onclick="useExample(this)">Build a Flask REST API with user CRUD endpoints</div>
|
| 178 |
+
<div class="example-btn" onclick="useExample(this)">Create a React todo app with localStorage persistence</div>
|
| 179 |
+
<div class="example-btn" onclick="useExample(this)">Write a Python web scraper with error handling</div>
|
| 180 |
+
<div class="example-btn" onclick="useExample(this)">Build a CLI tool that converts Markdown to HTML</div>
|
| 181 |
+
<div class="example-btn" onclick="useExample(this)">Create a FastAPI backend with JWT authentication</div>
|
| 182 |
+
</div>
|
| 183 |
+
</aside>
|
| 184 |
+
|
| 185 |
+
<!-- Main Chat -->
|
| 186 |
+
<div class="main">
|
| 187 |
+
<div class="chat-header">
|
| 188 |
+
<h2>Chat</h2>
|
| 189 |
+
<div class="header-actions">
|
| 190 |
+
<button class="header-btn" onclick="toggleWorkspace()">Workspace</button>
|
| 191 |
+
<button class="header-btn" onclick="refreshWorkspace()">Refresh</button>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<div class="chat-messages" id="chatMessages">
|
| 196 |
+
<div class="message" style="text-align:center; padding:60px 20px;">
|
| 197 |
+
<div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
|
| 198 |
+
<div style="color:var(--text-secondary); font-size:14px; max-width:500px; margin:0 auto; line-height:1.7;">
|
| 199 |
+
AI-powered code agent that writes, runs, and debugs code. Built with patterns from Gemini CLI and Claude Code. Supports MCP tools and multiple 1B models.
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div class="chat-input">
|
| 205 |
+
<div class="input-wrapper">
|
| 206 |
+
<textarea id="chatInput" placeholder="Describe what you want to build..." rows="1" onkeydown="handleKeyDown(event)" oninput="autoResize(this)"></textarea>
|
| 207 |
+
<button class="send-btn" id="sendBtn" onclick="sendMessage()">Send</button>
|
| 208 |
+
</div>
|
| 209 |
+
<div class="input-hint">Press Enter to send, Shift+Enter for new line</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<!-- Workspace Panel -->
|
| 214 |
+
<div class="workspace-panel" id="workspacePanel">
|
| 215 |
+
<div class="ws-header">
|
| 216 |
+
<h3>Workspace</h3>
|
| 217 |
+
</div>
|
| 218 |
+
<div class="ws-tree" id="wsTree">Loading...</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<script>
|
| 223 |
+
const __CONFIG__ = __RUNTIME_CONFIG__ || {};
|
| 224 |
+
|
| 225 |
+
// ββ State ββ
|
| 226 |
+
let sessionId = '';
|
| 227 |
+
let isStreaming = false;
|
| 228 |
+
let abortController = null;
|
| 229 |
+
let currentToolCalls = {};
|
| 230 |
+
|
| 231 |
+
// ββ Init ββ
|
| 232 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 233 |
+
loadConfig();
|
| 234 |
+
pollModelStatus();
|
| 235 |
+
refreshWorkspace();
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
async function loadConfig() {
|
| 239 |
+
try {
|
| 240 |
+
const resp = await fetch('/api/get_config');
|
| 241 |
+
const config = await resp.json();
|
| 242 |
+
const parsed = typeof config === 'string' ? JSON.parse(config) : config;
|
| 243 |
+
|
| 244 |
+
const select = document.getElementById('modelSelect');
|
| 245 |
+
select.innerHTML = '';
|
| 246 |
+
(parsed.models || []).forEach(m => {
|
| 247 |
+
const opt = document.createElement('option');
|
| 248 |
+
opt.value = m.key;
|
| 249 |
+
opt.textContent = `${m.name} (${m.size_gb}GB)`;
|
| 250 |
+
if (m.key === parsed.default_model) opt.selected = true;
|
| 251 |
+
select.appendChild(opt);
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
select.addEventListener('change', () => switchModel(select.value));
|
| 255 |
+
} catch(e) { console.error('Config load failed:', e); }
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
async function pollModelStatus() {
|
| 259 |
+
try {
|
| 260 |
+
const resp = await fetch('/api/model_status');
|
| 261 |
+
const status = JSON.parse(await resp.json());
|
| 262 |
+
const dot = document.getElementById('statusDot');
|
| 263 |
+
const text = document.getElementById('statusText');
|
| 264 |
+
|
| 265 |
+
dot.className = 'status-dot ' + (status.status || 'unknown');
|
| 266 |
+
const names = { ready:'Ready', loading:'Loading...', error:'Error', unknown:'Unknown' };
|
| 267 |
+
text.textContent = (status.model_name || names[status.status] || 'Unknown');
|
| 268 |
+
|
| 269 |
+
if (status.status === 'loading') {
|
| 270 |
+
setTimeout(pollModelStatus, 3000);
|
| 271 |
+
}
|
| 272 |
+
} catch(e) { setTimeout(pollModelStatus, 5000); }
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
async function switchModel(key) {
|
| 276 |
+
try {
|
| 277 |
+
await fetch('/api/switch_model', { method:'POST', body: new URLSearchParams({data: JSON.stringify([key])}), headers:{'Content-Type':'application/x-www-form-urlencoded'} });
|
| 278 |
+
document.getElementById('statusDot').className = 'status-dot loading';
|
| 279 |
+
document.getElementById('statusText').textContent = 'Switching...';
|
| 280 |
+
setTimeout(pollModelStatus, 2000);
|
| 281 |
+
} catch(e) { console.error(e); }
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
async function refreshWorkspace() {
|
| 285 |
+
try {
|
| 286 |
+
const resp = await fetch('/api/workspace_tree', { method:'POST', body: new URLSearchParams({data: JSON.stringify([3])}), headers:{'Content-Type':'application/x-www-form-urlencoded'} });
|
| 287 |
+
const data = JSON.parse(await resp.json());
|
| 288 |
+
document.getElementById('wsTree').textContent = data.tree || '(empty)';
|
| 289 |
+
} catch(e) { console.error(e); }
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function toggleWorkspace() {
|
| 293 |
+
document.getElementById('workspacePanel').classList.toggle('collapsed');
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function newChat() {
|
| 297 |
+
if (sessionId) {
|
| 298 |
+
fetch('/api/reset_session', { method:'POST', body: new URLSearchParams({data: JSON.stringify([sessionId])}), headers:{'Content-Type':'application/x-www-form-urlencoded'} });
|
| 299 |
+
}
|
| 300 |
+
sessionId = '';
|
| 301 |
+
document.getElementById('chatMessages').innerHTML = `
|
| 302 |
+
<div class="message" style="text-align:center; padding:60px 20px;">
|
| 303 |
+
<div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
|
| 304 |
+
<div style="color:var(--text-secondary); font-size:14px;">New session started. What would you like to build?</div>
|
| 305 |
+
</div>`;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function useExample(btn) {
|
| 309 |
+
document.getElementById('chatInput').value = btn.textContent.trim();
|
| 310 |
+
sendMessage();
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function handleKeyDown(e) {
|
| 314 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 315 |
+
e.preventDefault();
|
| 316 |
+
sendMessage();
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function autoResize(el) {
|
| 321 |
+
el.style.height = 'auto';
|
| 322 |
+
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// ββ Message Rendering ββ
|
| 326 |
+
function appendMessage(role, content, extra={}) {
|
| 327 |
+
const container = document.getElementById('chatMessages');
|
| 328 |
+
const div = document.createElement('div');
|
| 329 |
+
div.className = 'message';
|
| 330 |
+
div.id = extra.msgId || '';
|
| 331 |
+
|
| 332 |
+
const time = new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
| 333 |
+
const avatarClass = role === 'user' ? 'user' : 'agent';
|
| 334 |
+
const avatarText = role === 'user' ? 'Y' : 'SC';
|
| 335 |
+
|
| 336 |
+
div.innerHTML = `
|
| 337 |
+
<div class="message-header">
|
| 338 |
+
<div class="message-avatar ${avatarClass}">${avatarText}</div>
|
| 339 |
+
<span class="message-role">${role === 'user' ? 'You' : 'SoniCoder'}</span>
|
| 340 |
+
<span class="message-time">${time}</span>
|
| 341 |
+
${extra.iteration ? `<span class="iteration-badge">Iteration ${extra.iteration}</span>` : ''}
|
| 342 |
+
</div>
|
| 343 |
+
<div class="message-body">${formatContent(content)}</div>`;
|
| 344 |
+
|
| 345 |
+
container.appendChild(div);
|
| 346 |
+
container.scrollTop = container.scrollHeight;
|
| 347 |
+
return div;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function formatContent(text) {
|
| 351 |
+
if (!text) return '';
|
| 352 |
+
// Code blocks
|
| 353 |
+
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
| 354 |
+
return `<pre><code class="language-${lang}">${escapeHtml(code.trim())}</code></pre>`;
|
| 355 |
+
});
|
| 356 |
+
// Inline code
|
| 357 |
+
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
| 358 |
+
// Bold
|
| 359 |
+
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
| 360 |
+
// Lists
|
| 361 |
+
text = text.replace(/^[-β’] (.+)$/gm, '<li>$1</li>');
|
| 362 |
+
text = text.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
| 363 |
+
// Line breaks
|
| 364 |
+
text = text.replace(/\n/g, '<br>');
|
| 365 |
+
return text;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function escapeHtml(text) {
|
| 369 |
+
const d = document.createElement('div');
|
| 370 |
+
d.textContent = text;
|
| 371 |
+
return d.innerHTML;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function addToolCall(toolName, args) {
|
| 375 |
+
const container = document.getElementById('chatMessages');
|
| 376 |
+
const id = 'tc_' + Date.now();
|
| 377 |
+
const div = document.createElement('div');
|
| 378 |
+
div.className = 'tool-call';
|
| 379 |
+
div.id = id;
|
| 380 |
+
div.innerHTML = `
|
| 381 |
+
<div class="tool-call-header">
|
| 382 |
+
<span class="tool-icon">β‘</span>
|
| 383 |
+
<span class="tool-name">${escapeHtml(toolName)}</span>
|
| 384 |
+
<span class="tool-status running">Running</span>
|
| 385 |
+
</div>
|
| 386 |
+
<div class="tool-args">${formatToolArgs(args)}</div>
|
| 387 |
+
<div class="tool-result" style="display:none;"></div>`;
|
| 388 |
+
container.appendChild(div);
|
| 389 |
+
container.scrollTop = container.scrollHeight;
|
| 390 |
+
currentToolCalls[id] = div;
|
| 391 |
+
return id;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
function updateToolCall(id, success, output) {
|
| 395 |
+
const div = currentToolCalls[id];
|
| 396 |
+
if (!div) return;
|
| 397 |
+
const status = div.querySelector('.tool-status');
|
| 398 |
+
const result = div.querySelector('.tool-result');
|
| 399 |
+
status.className = 'tool-status ' + (success ? 'success' : 'error');
|
| 400 |
+
status.textContent = success ? 'Done' : 'Error';
|
| 401 |
+
if (output) {
|
| 402 |
+
result.textContent = output;
|
| 403 |
+
result.style.display = 'block';
|
| 404 |
+
}
|
| 405 |
+
document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
function formatToolArgs(args) {
|
| 409 |
+
if (!args) return '';
|
| 410 |
+
const lines = Object.entries(args).map(([k, v]) => {
|
| 411 |
+
const val = typeof v === 'string' ? (v.length > 60 ? v.slice(0, 60) + '...' : v) : JSON.stringify(v);
|
| 412 |
+
return `${k}: ${val}`;
|
| 413 |
+
});
|
| 414 |
+
return escapeHtml(lines.join('\n'));
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
// ββ Streaming ββ
|
| 418 |
+
let currentStreamDiv = null;
|
| 419 |
+
let currentStreamContent = '';
|
| 420 |
+
|
| 421 |
+
function startStreaming() {
|
| 422 |
+
currentStreamContent = '';
|
| 423 |
+
const container = document.getElementById('chatMessages');
|
| 424 |
+
// Remove welcome message if present
|
| 425 |
+
const welcome = container.querySelector('.message[style*="text-align:center"]');
|
| 426 |
+
if (welcome) welcome.remove();
|
| 427 |
+
|
| 428 |
+
currentStreamDiv = appendMessage('agent', '', {});
|
| 429 |
+
const body = currentStreamDiv.querySelector('.message-body');
|
| 430 |
+
body.innerHTML = '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
function updateStreaming(text) {
|
| 434 |
+
if (!currentStreamDiv) return;
|
| 435 |
+
const body = currentStreamDiv.querySelector('.message-body');
|
| 436 |
+
body.innerHTML = formatContent(text) || '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
|
| 437 |
+
document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
function finalizeStream(text) {
|
| 441 |
+
if (!currentStreamDiv) return;
|
| 442 |
+
const body = currentStreamDiv.querySelector('.message-body');
|
| 443 |
+
body.innerHTML = formatContent(text);
|
| 444 |
+
currentStreamDiv = null;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
// ββ Send Message ββ
|
| 448 |
+
async function sendMessage() {
|
| 449 |
+
const input = document.getElementById('chatInput');
|
| 450 |
+
const message = input.value.trim();
|
| 451 |
+
if (!message || isStreaming) return;
|
| 452 |
+
|
| 453 |
+
input.value = '';
|
| 454 |
+
input.style.height = 'auto';
|
| 455 |
+
isStreaming = true;
|
| 456 |
+
|
| 457 |
+
const sendBtn = document.getElementById('sendBtn');
|
| 458 |
+
sendBtn.textContent = 'Stop';
|
| 459 |
+
sendBtn.className = 'stop-btn';
|
| 460 |
+
sendBtn.onclick = stopStreaming;
|
| 461 |
+
|
| 462 |
+
appendMessage('user', message);
|
| 463 |
+
startStreaming();
|
| 464 |
+
|
| 465 |
+
try {
|
| 466 |
+
const resp = await fetch('/api/chat', {
|
| 467 |
+
method: 'POST',
|
| 468 |
+
body: new URLSearchParams({
|
| 469 |
+
data: JSON.stringify([message, sessionId, '']),
|
| 470 |
+
}),
|
| 471 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 472 |
+
});
|
| 473 |
+
|
| 474 |
+
const reader = resp.body.getReader();
|
| 475 |
+
const decoder = new TextDecoder();
|
| 476 |
+
let buffer = '';
|
| 477 |
+
|
| 478 |
+
while (true) {
|
| 479 |
+
const { done, value } = await reader.read();
|
| 480 |
+
if (done) break;
|
| 481 |
+
|
| 482 |
+
buffer += decoder.decode(value, { stream: true });
|
| 483 |
+
const lines = buffer.split('\n');
|
| 484 |
+
buffer = lines.pop() || '';
|
| 485 |
+
|
| 486 |
+
for (const line of lines) {
|
| 487 |
+
if (!line.trim()) continue;
|
| 488 |
+
try {
|
| 489 |
+
const event = JSON.parse(line);
|
| 490 |
+
|
| 491 |
+
switch (event.type) {
|
| 492 |
+
case 'streaming':
|
| 493 |
+
const display = (event.content || '').replace(/```tool[\s\S]*?```/g, '').trim();
|
| 494 |
+
updateStreaming(display);
|
| 495 |
+
break;
|
| 496 |
+
|
| 497 |
+
case 'tool_call':
|
| 498 |
+
addToolCall(event.tool, event.args);
|
| 499 |
+
break;
|
| 500 |
+
|
| 501 |
+
case 'tool_result':
|
| 502 |
+
// Find the latest tool call for this tool name
|
| 503 |
+
const tcIds = Object.keys(currentToolCalls).reverse();
|
| 504 |
+
for (const tcId of tcIds) {
|
| 505 |
+
const tcDiv = currentToolCalls[tcId];
|
| 506 |
+
if (tcDiv && tcDiv.querySelector('.tool-status.running')) {
|
| 507 |
+
updateToolCall(tcId, event.result.success, event.result.output || event.result.error);
|
| 508 |
+
break;
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
break;
|
| 512 |
+
|
| 513 |
+
case 'status':
|
| 514 |
+
if (event.iteration) {
|
| 515 |
+
const header = currentStreamDiv?.querySelector('.message-header');
|
| 516 |
+
if (header) {
|
| 517 |
+
let badge = header.querySelector('.iteration-badge');
|
| 518 |
+
if (!badge) {
|
| 519 |
+
badge = document.createElement('span');
|
| 520 |
+
badge.className = 'iteration-badge';
|
| 521 |
+
header.appendChild(badge);
|
| 522 |
+
}
|
| 523 |
+
badge.textContent = `Iteration ${event.iteration}/${event.max_iterations}`;
|
| 524 |
+
}
|
| 525 |
+
}
|
| 526 |
+
break;
|
| 527 |
+
|
| 528 |
+
case 'complete':
|
| 529 |
+
const finalText = event.content || currentStreamContent;
|
| 530 |
+
finalizeStream(finalText || 'Done.');
|
| 531 |
+
if (event.iterations) {
|
| 532 |
+
setTimeout(refreshWorkspace, 500);
|
| 533 |
+
}
|
| 534 |
+
break;
|
| 535 |
+
|
| 536 |
+
case 'error':
|
| 537 |
+
finalizeStream(`Error: ${event.error}`);
|
| 538 |
+
break;
|
| 539 |
+
}
|
| 540 |
+
} catch (e) { /* skip malformed lines */ }
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
} catch (e) {
|
| 544 |
+
if (e.name !== 'AbortError') {
|
| 545 |
+
finalizeStream(`Connection error: ${e.message}`);
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
isStreaming = false;
|
| 550 |
+
sendBtn.textContent = 'Send';
|
| 551 |
+
sendBtn.className = 'send-btn';
|
| 552 |
+
sendBtn.onclick = sendMessage;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function stopStreaming() {
|
| 556 |
+
// The streaming will naturally end when we close
|
| 557 |
+
isStreaming = false;
|
| 558 |
+
const sendBtn = document.getElementById('sendBtn');
|
| 559 |
+
sendBtn.textContent = 'Send';
|
| 560 |
+
sendBtn.className = 'send-btn';
|
| 561 |
+
sendBtn.onclick = sendMessage;
|
| 562 |
+
if (currentStreamDiv) {
|
| 563 |
+
finalizeStream(currentStreamContent || '(stopped)');
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
</script>
|
| 567 |
+
</body>
|
| 568 |
+
</html>
|