Spaces:
Running
Running
fix: consolidate to code/ only, fix bugs, add missing UI options
Browse files- Removed duplicated sonicoder/ package (paths now only in code/)
- Removed duplicate static/index.html
- Added code/mcp/ and code/policy/ modules (previously missing)
- Added create_app() to code/server/__init__.py
- Added v2 REST endpoints to code/server/routes.py
- Fixed UnboundLocalError in code/model/loader.py switch_model
- Fixed JSONResponse default=str bug in routes.py
- Added UI: language/framework/skill/agent selectors, web search toggle, image upload
- Updated app.py to import from code.* only
- app.py +35 -36
- code/config/__init__.py +274 -1
- {sonicoder → code}/mcp/__init__.py +56 -25
- code/model/loader.py +1 -1
- {sonicoder → code}/policy/__init__.py +43 -7
- code/server/__init__.py +31 -1
- code/server/routes.py +413 -1
- index.html +278 -213
- requirements.txt +2 -4
- sonicoder/__init__.py +0 -12
- sonicoder/agent/__init__.py +0 -474
- sonicoder/agent/protocol.py +0 -106
- sonicoder/config/__init__.py +0 -455
- sonicoder/context/__init__.py +0 -154
- sonicoder/execution/__init__.py +0 -0
- sonicoder/mcp/client_manager.py +0 -0
- sonicoder/model/__init__.py +0 -0
- sonicoder/model/inference.py +0 -224
- sonicoder/model/loader.py +0 -181
- sonicoder/plugins/__init__.py +0 -0
- sonicoder/server/__init__.py +0 -413
- sonicoder/skills/__init__.py +0 -0
- sonicoder/tools/__init__.py +0 -215
- sonicoder/tools/bash.py +0 -143
- sonicoder/tools/fs.py +0 -508
- sonicoder/tools/todos.py +0 -109
- sonicoder/tools/web.py +0 -163
- static/index.html +0 -568
app.py
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
"""SoniCoder v2 — Entry point.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import logging
|
| 12 |
-
import os
|
| 13 |
|
| 14 |
-
# Ensure workspace
|
| 15 |
-
from
|
|
|
|
| 16 |
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 17 |
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
| 18 |
|
|
@@ -27,39 +27,38 @@ logger = logging.getLogger(__name__)
|
|
| 27 |
def main():
|
| 28 |
logger.info("SoniCoder v2 starting...")
|
| 29 |
|
| 30 |
-
#
|
| 31 |
-
from
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
| 36 |
try:
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
from sonicoder.server import create_app
|
| 57 |
app = create_app()
|
| 58 |
|
| 59 |
-
logger.info("Launching
|
| 60 |
-
|
| 61 |
-
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
|
| 62 |
|
| 63 |
|
| 64 |
if __name__ == "__main__":
|
| 65 |
-
main()
|
|
|
|
| 1 |
"""SoniCoder v2 — Entry point.
|
| 2 |
|
| 3 |
+
gradio.Server (Gradio 6.x) serves index.html from project root
|
| 4 |
+
with Gradio's queuing, concurrency, and API infrastructure.
|
| 5 |
|
| 6 |
+
Blog: https://huggingface.co/blog/introducing-gradio-server
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import logging
|
|
|
|
| 12 |
|
| 13 |
+
# Ensure workspace + config dirs exist
|
| 14 |
+
from code.config import WORKSPACE_ROOT, CONFIG_DIR
|
| 15 |
+
|
| 16 |
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 17 |
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
| 18 |
|
|
|
|
| 27 |
def main():
|
| 28 |
logger.info("SoniCoder v2 starting...")
|
| 29 |
|
| 30 |
+
# Start loading default model in background
|
| 31 |
+
from code.model.loader import start_background_load
|
| 32 |
+
|
| 33 |
+
start_background_load()
|
| 34 |
+
logger.info("Background model loading started")
|
| 35 |
+
|
| 36 |
+
# Set up MCP servers from config (best-effort)
|
| 37 |
try:
|
| 38 |
+
from code.config import load_settings
|
| 39 |
+
from code.mcp import get_mcp_manager
|
| 40 |
+
|
| 41 |
+
settings = load_settings()
|
| 42 |
+
if settings.mcp_servers:
|
| 43 |
+
mcp = get_mcp_manager()
|
| 44 |
+
mcp.configure(settings.mcp_servers)
|
| 45 |
+
results = mcp.connect_all()
|
| 46 |
+
for name, error in results.items():
|
| 47 |
+
if error:
|
| 48 |
+
logger.warning("MCP server '%s' failed: %s", name, error)
|
| 49 |
+
else:
|
| 50 |
+
logger.info("MCP server '%s' connected", name)
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
logger.warning("MCP setup skipped: %s", exc)
|
| 53 |
+
|
| 54 |
+
# Create the server (gradio.Server — custom HTML + Gradio backend)
|
| 55 |
+
from code.server import create_app
|
| 56 |
+
|
|
|
|
| 57 |
app = create_app()
|
| 58 |
|
| 59 |
+
logger.info("Launching...")
|
| 60 |
+
app.launch(show_error=True, server_name="0.0.0.0", server_port=7860)
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
if __name__ == "__main__":
|
| 64 |
+
main()
|
code/config/__init__.py
CHANGED
|
@@ -1 +1,274 @@
|
|
| 1 |
-
"""Configuration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration system — hierarchical settings + constants.
|
| 2 |
+
|
| 3 |
+
This module combines:
|
| 4 |
+
- constants.py (app-wide constants, model registry, system prompt, languages)
|
| 5 |
+
- settings (hierarchical Settings, MCP server config, policy rules, load_settings)
|
| 6 |
+
|
| 7 |
+
Path layout:
|
| 8 |
+
- WORKSPACE_ROOT: where the agent's sandboxed filesystem lives
|
| 9 |
+
- CONFIG_DIR: where settings/agents/hooks/skills are stored (default: .sonicoder/)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
# ── Re-export constants for backward compatibility ─────────────────────
|
| 21 |
+
from code.config.constants import (
|
| 22 |
+
APP_TITLE,
|
| 23 |
+
DEFAULT_MAX_TOKENS,
|
| 24 |
+
DEFAULT_MODEL_KEY,
|
| 25 |
+
DEFAULT_TEMPERATURE,
|
| 26 |
+
EXAMPLE_PROMPTS,
|
| 27 |
+
LANGUAGE_MAP,
|
| 28 |
+
LANGUAGE_OPTIONS,
|
| 29 |
+
MODEL_CONFIGS,
|
| 30 |
+
MODEL_ID,
|
| 31 |
+
MODEL_URL,
|
| 32 |
+
SYSTEM_PROMPT,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# ── Path Constants ─────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
WORKSPACE_ROOT = Path(
|
| 38 |
+
os.environ.get(
|
| 39 |
+
"SONICODER_WORKSPACE",
|
| 40 |
+
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "workspace")),
|
| 41 |
+
)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
CONFIG_DIR = Path(
|
| 45 |
+
os.environ.get(
|
| 46 |
+
"SONICODER_CONFIG",
|
| 47 |
+
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".sonicoder")),
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ── Runtime Defaults (override constants) ──────────────────────────────
|
| 53 |
+
|
| 54 |
+
DEFAULT_MAX_ITERATIONS = 12
|
| 55 |
+
AGENT_LOOP_TIMEOUT = 300 # seconds
|
| 56 |
+
BASH_TIMEOUT = 120
|
| 57 |
+
PYTHON_TIMEOUT = 30
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── Settings Dataclasses ───────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass
|
| 64 |
+
class ModelConfig:
|
| 65 |
+
"""Configuration for a single model."""
|
| 66 |
+
|
| 67 |
+
name: str
|
| 68 |
+
model_id: str
|
| 69 |
+
model_type: str # "text" | "vlm"
|
| 70 |
+
size_gb: float
|
| 71 |
+
description: str
|
| 72 |
+
dtype: str = "float16"
|
| 73 |
+
trust_remote_code: bool = True
|
| 74 |
+
device: str = "auto"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class MCPServerConfig:
|
| 79 |
+
"""Configuration for an MCP server."""
|
| 80 |
+
|
| 81 |
+
name: str
|
| 82 |
+
transport: str # "stdio" | "sse"
|
| 83 |
+
command: str | None = None
|
| 84 |
+
args: list[str] = field(default_factory=list)
|
| 85 |
+
env: dict[str, str] = field(default_factory=dict)
|
| 86 |
+
url: str | None = None
|
| 87 |
+
enabled: bool = True
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@dataclass
|
| 91 |
+
class PolicyRule:
|
| 92 |
+
"""A policy rule for tool approval."""
|
| 93 |
+
|
| 94 |
+
tool_name: str # supports "*" wildcard
|
| 95 |
+
args_pattern: str | None = None
|
| 96 |
+
approval: str = "ask" # "allow" | "ask" | "deny"
|
| 97 |
+
priority: int = 0
|
| 98 |
+
description: str = ""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass
|
| 102 |
+
class Settings:
|
| 103 |
+
"""Hierarchical settings — cascades from global → project → local."""
|
| 104 |
+
|
| 105 |
+
# Model
|
| 106 |
+
default_model: str = DEFAULT_MODEL_KEY
|
| 107 |
+
temperature: float = DEFAULT_TEMPERATURE
|
| 108 |
+
max_tokens: int = DEFAULT_MAX_TOKENS
|
| 109 |
+
|
| 110 |
+
# Agent
|
| 111 |
+
max_iterations: int = DEFAULT_MAX_ITERATIONS
|
| 112 |
+
agent_timeout: int = AGENT_LOOP_TIMEOUT
|
| 113 |
+
|
| 114 |
+
# Tools
|
| 115 |
+
enabled_tools: list[str] = field(default_factory=lambda: ["*"])
|
| 116 |
+
disabled_tools: list[str] = field(default_factory=list)
|
| 117 |
+
|
| 118 |
+
# Safety
|
| 119 |
+
sandbox_enabled: bool = True
|
| 120 |
+
bash_timeout: int = BASH_TIMEOUT
|
| 121 |
+
python_timeout: int = PYTHON_TIMEOUT
|
| 122 |
+
|
| 123 |
+
# MCP
|
| 124 |
+
mcp_servers: list[MCPServerConfig] = field(default_factory=list)
|
| 125 |
+
|
| 126 |
+
# Policy
|
| 127 |
+
policy_rules: list[PolicyRule] = field(default_factory=list)
|
| 128 |
+
|
| 129 |
+
# Context
|
| 130 |
+
max_context_tokens: int = 32768
|
| 131 |
+
auto_compact: bool = True
|
| 132 |
+
|
| 133 |
+
@classmethod
|
| 134 |
+
def from_dict(cls, data: dict[str, Any]) -> Settings:
|
| 135 |
+
"""Create settings from a dictionary, ignoring unknown fields."""
|
| 136 |
+
known_fields = {f.name for f in cls.__dataclass_fields__.values()}
|
| 137 |
+
filtered = {k: v for k, v in data.items() if k in known_fields}
|
| 138 |
+
return cls(**filtered)
|
| 139 |
+
|
| 140 |
+
def merge(self, other: Settings) -> Settings:
|
| 141 |
+
"""Merge another settings object (other takes precedence for non-default values)."""
|
| 142 |
+
result = Settings(
|
| 143 |
+
default_model=other.default_model or self.default_model,
|
| 144 |
+
temperature=(
|
| 145 |
+
other.temperature
|
| 146 |
+
if other.temperature != DEFAULT_TEMPERATURE
|
| 147 |
+
else self.temperature
|
| 148 |
+
),
|
| 149 |
+
max_tokens=(
|
| 150 |
+
other.max_tokens
|
| 151 |
+
if other.max_tokens != DEFAULT_MAX_TOKENS
|
| 152 |
+
else self.max_tokens
|
| 153 |
+
),
|
| 154 |
+
max_iterations=(
|
| 155 |
+
other.max_iterations
|
| 156 |
+
if other.max_iterations != DEFAULT_MAX_ITERATIONS
|
| 157 |
+
else self.max_iterations
|
| 158 |
+
),
|
| 159 |
+
agent_timeout=(
|
| 160 |
+
other.agent_timeout
|
| 161 |
+
if other.agent_timeout != AGENT_LOOP_TIMEOUT
|
| 162 |
+
else self.agent_timeout
|
| 163 |
+
),
|
| 164 |
+
sandbox_enabled=other.sandbox_enabled,
|
| 165 |
+
bash_timeout=other.bash_timeout,
|
| 166 |
+
python_timeout=other.python_timeout,
|
| 167 |
+
auto_compact=other.auto_compact,
|
| 168 |
+
)
|
| 169 |
+
# Merge lists
|
| 170 |
+
result.enabled_tools = (
|
| 171 |
+
other.enabled_tools if other.enabled_tools != ["*"] else self.enabled_tools
|
| 172 |
+
)
|
| 173 |
+
result.disabled_tools = list(set(self.disabled_tools + other.disabled_tools))
|
| 174 |
+
result.mcp_servers = self.mcp_servers + other.mcp_servers
|
| 175 |
+
result.policy_rules = other.policy_rules + self.policy_rules
|
| 176 |
+
return result
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ── Public Helpers ─────────────────────────────────────────────────────
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def get_available_models() -> dict[str, dict[str, Any]]:
|
| 183 |
+
"""Return all registered model configs (from constants.MODEL_CONFIGS)."""
|
| 184 |
+
return MODEL_CONFIGS.copy()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def get_model_config(model_key: str) -> dict[str, Any] | None:
|
| 188 |
+
"""Get a model config by key."""
|
| 189 |
+
return MODEL_CONFIGS.get(model_key)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def load_settings(project_dir: Path | None = None) -> Settings:
|
| 193 |
+
"""Load settings with hierarchical cascading.
|
| 194 |
+
|
| 195 |
+
Priority (highest → lowest):
|
| 196 |
+
1. Environment variables
|
| 197 |
+
2. .sonicoder/settings.local.json (local overrides, gitignored)
|
| 198 |
+
3. .sonicoder/settings.json (project settings)
|
| 199 |
+
4. Defaults
|
| 200 |
+
"""
|
| 201 |
+
settings = Settings()
|
| 202 |
+
|
| 203 |
+
search_dirs = [Path.cwd()]
|
| 204 |
+
if project_dir:
|
| 205 |
+
search_dirs.insert(0, project_dir)
|
| 206 |
+
|
| 207 |
+
# Load project-level settings
|
| 208 |
+
for d in search_dirs:
|
| 209 |
+
local_path = d / CONFIG_DIR / "settings.local.json"
|
| 210 |
+
proj_path = d / CONFIG_DIR / "settings.json"
|
| 211 |
+
|
| 212 |
+
for path in [local_path, proj_path]:
|
| 213 |
+
if path.exists():
|
| 214 |
+
try:
|
| 215 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 216 |
+
# Parse MCP server configs
|
| 217 |
+
if "mcp_servers" in data:
|
| 218 |
+
data["mcp_servers"] = [
|
| 219 |
+
MCPServerConfig(**s) for s in data["mcp_servers"]
|
| 220 |
+
]
|
| 221 |
+
if "policy_rules" in data:
|
| 222 |
+
data["policy_rules"] = [
|
| 223 |
+
PolicyRule(**r) for r in data["policy_rules"]
|
| 224 |
+
]
|
| 225 |
+
settings = settings.merge(Settings.from_dict(data))
|
| 226 |
+
except (json.JSONDecodeError, TypeError) as e:
|
| 227 |
+
print(f"[config] Warning: Failed to parse {path}: {e}")
|
| 228 |
+
|
| 229 |
+
# Environment variable overrides
|
| 230 |
+
env_model = os.environ.get("SONICODER_MODEL")
|
| 231 |
+
if env_model and env_model in MODEL_CONFIGS:
|
| 232 |
+
settings.default_model = env_model
|
| 233 |
+
|
| 234 |
+
env_temp = os.environ.get("SONICODER_TEMPERATURE")
|
| 235 |
+
if env_temp:
|
| 236 |
+
try:
|
| 237 |
+
settings.temperature = float(env_temp)
|
| 238 |
+
except ValueError:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
return settings
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
__all__ = [
|
| 245 |
+
# Constants
|
| 246 |
+
"APP_TITLE",
|
| 247 |
+
"DEFAULT_MAX_TOKENS",
|
| 248 |
+
"DEFAULT_MODEL_KEY",
|
| 249 |
+
"DEFAULT_TEMPERATURE",
|
| 250 |
+
"EXAMPLE_PROMPTS",
|
| 251 |
+
"LANGUAGE_MAP",
|
| 252 |
+
"LANGUAGE_OPTIONS",
|
| 253 |
+
"MODEL_CONFIGS",
|
| 254 |
+
"MODEL_ID",
|
| 255 |
+
"MODEL_URL",
|
| 256 |
+
"SYSTEM_PROMPT",
|
| 257 |
+
# Path constants
|
| 258 |
+
"WORKSPACE_ROOT",
|
| 259 |
+
"CONFIG_DIR",
|
| 260 |
+
# Runtime defaults
|
| 261 |
+
"DEFAULT_MAX_ITERATIONS",
|
| 262 |
+
"AGENT_LOOP_TIMEOUT",
|
| 263 |
+
"BASH_TIMEOUT",
|
| 264 |
+
"PYTHON_TIMEOUT",
|
| 265 |
+
# Dataclasses
|
| 266 |
+
"ModelConfig",
|
| 267 |
+
"MCPServerConfig",
|
| 268 |
+
"PolicyRule",
|
| 269 |
+
"Settings",
|
| 270 |
+
# Helpers
|
| 271 |
+
"get_available_models",
|
| 272 |
+
"get_model_config",
|
| 273 |
+
"load_settings",
|
| 274 |
+
]
|
{sonicoder → code}/mcp/__init__.py
RENAMED
|
@@ -17,13 +17,14 @@ import logging
|
|
| 17 |
import threading
|
| 18 |
from typing import Any, Callable
|
| 19 |
|
| 20 |
-
from .
|
| 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 |
|
|
@@ -31,7 +32,13 @@ class MCPToolBridge:
|
|
| 31 |
interface so they get the same policy, confirmation, and scheduling treatment.
|
| 32 |
"""
|
| 33 |
|
| 34 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
self.server_name = server_name
|
| 36 |
self.tool_name = tool_name
|
| 37 |
self._schema = tool_schema
|
|
@@ -47,7 +54,9 @@ class MCPToolBridge:
|
|
| 47 |
return {
|
| 48 |
"name": self.name,
|
| 49 |
"description": self.description,
|
| 50 |
-
"parameters": self._schema.get(
|
|
|
|
|
|
|
| 51 |
"kind": self.kind,
|
| 52 |
"mcp_server": self.server_name,
|
| 53 |
"mcp_tool": self.tool_name,
|
|
@@ -60,6 +69,7 @@ class MCPToolBridge:
|
|
| 60 |
|
| 61 |
# ── MCP Client Manager ─────────────────────────────────────────────────
|
| 62 |
|
|
|
|
| 63 |
class MCPClientManager:
|
| 64 |
"""Manages multiple MCP server connections.
|
| 65 |
|
|
@@ -99,7 +109,7 @@ class MCPClientManager:
|
|
| 99 |
results[name] = error
|
| 100 |
except Exception as e:
|
| 101 |
results[name] = str(e)
|
| 102 |
-
logger.error(
|
| 103 |
return results
|
| 104 |
|
| 105 |
def disconnect_all(self) -> None:
|
|
@@ -115,7 +125,9 @@ class MCPClientManager:
|
|
| 115 |
"""Get a specific MCP tool by its full name (mcp_server_tool)."""
|
| 116 |
return self._tools.get(name)
|
| 117 |
|
| 118 |
-
def call_tool(
|
|
|
|
|
|
|
| 119 |
"""Call an MCP tool on a specific server."""
|
| 120 |
client = self._clients.get(server_name)
|
| 121 |
if not client:
|
|
@@ -156,15 +168,17 @@ class MCPClientManager:
|
|
| 156 |
if client and hasattr(client, "list_resources"):
|
| 157 |
try:
|
| 158 |
result = client.list_resources()
|
| 159 |
-
for r in
|
| 160 |
-
resources.append(
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
| 166 |
except Exception as e:
|
| 167 |
-
logger.warning(
|
| 168 |
return resources
|
| 169 |
|
| 170 |
def get_status(self) -> dict[str, Any]:
|
|
@@ -175,7 +189,9 @@ class MCPClientManager:
|
|
| 175 |
"enabled": config.enabled,
|
| 176 |
"connected": name in self._connected,
|
| 177 |
"transport": config.transport,
|
| 178 |
-
"tool_count": sum(
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
return status
|
| 181 |
|
|
@@ -189,8 +205,8 @@ class MCPClientManager:
|
|
| 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)"
|
|
@@ -212,7 +228,11 @@ class MCPClientManager:
|
|
| 212 |
|
| 213 |
# Discover tools
|
| 214 |
self._discover_tools(name, client_session)
|
| 215 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
return None
|
| 217 |
|
| 218 |
elif config.transport == "sse":
|
|
@@ -221,12 +241,12 @@ class MCPClientManager:
|
|
| 221 |
return f"Unknown transport: {config.transport}"
|
| 222 |
|
| 223 |
except Exception as e:
|
| 224 |
-
logger.error(
|
| 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
|
| 230 |
from mcp.client.stdio import stdio_client
|
| 231 |
|
| 232 |
result = {"session": None, "error": None}
|
|
@@ -243,7 +263,9 @@ class MCPClientManager:
|
|
| 243 |
result["error"] = str(e)
|
| 244 |
|
| 245 |
loop = asyncio.new_event_loop()
|
| 246 |
-
thread = threading.Thread(
|
|
|
|
|
|
|
| 247 |
thread.start()
|
| 248 |
thread.join(timeout=10)
|
| 249 |
|
|
@@ -269,7 +291,9 @@ class MCPClientManager:
|
|
| 269 |
for tool in tools:
|
| 270 |
schema = {
|
| 271 |
"description": getattr(tool, "description", ""),
|
| 272 |
-
"inputSchema": getattr(
|
|
|
|
|
|
|
| 273 |
"annotations": getattr(tool, "annotations", {}),
|
| 274 |
}
|
| 275 |
tool_name = getattr(tool, "name", "unknown")
|
|
@@ -278,13 +302,14 @@ class MCPClientManager:
|
|
| 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:
|
|
|
|
| 282 |
)
|
| 283 |
self._tools[bridge.name] = bridge
|
| 284 |
-
logger.debug(
|
| 285 |
|
| 286 |
except Exception as e:
|
| 287 |
-
logger.warning(
|
| 288 |
|
| 289 |
def _disconnect(self, name: str) -> None:
|
| 290 |
"""Disconnect from an MCP server."""
|
|
@@ -306,12 +331,14 @@ class _AsyncSessionWrapper:
|
|
| 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)
|
|
@@ -319,6 +346,7 @@ class _AsyncSessionWrapper:
|
|
| 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 |
|
|
@@ -333,4 +361,7 @@ def get_mcp_manager() -> MCPClientManager:
|
|
| 333 |
global _global_manager
|
| 334 |
if _global_manager is None:
|
| 335 |
_global_manager = MCPClientManager()
|
| 336 |
-
return _global_manager
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
import threading
|
| 18 |
from typing import Any, Callable
|
| 19 |
|
| 20 |
+
from code.config import MCPServerConfig
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
| 24 |
|
| 25 |
# ── MCP Tool Bridge ────────────────────────────────────────────────────
|
| 26 |
|
| 27 |
+
|
| 28 |
class MCPToolBridge:
|
| 29 |
"""Wraps an MCP tool as a ToolBuilder, making it indistinguishable from built-in tools.
|
| 30 |
|
|
|
|
| 32 |
interface so they get the same policy, confirmation, and scheduling treatment.
|
| 33 |
"""
|
| 34 |
|
| 35 |
+
def __init__(
|
| 36 |
+
self,
|
| 37 |
+
server_name: str,
|
| 38 |
+
tool_name: str,
|
| 39 |
+
tool_schema: dict,
|
| 40 |
+
call_fn: Callable,
|
| 41 |
+
):
|
| 42 |
self.server_name = server_name
|
| 43 |
self.tool_name = tool_name
|
| 44 |
self._schema = tool_schema
|
|
|
|
| 54 |
return {
|
| 55 |
"name": self.name,
|
| 56 |
"description": self.description,
|
| 57 |
+
"parameters": self._schema.get(
|
| 58 |
+
"inputSchema", {"type": "object", "properties": {}}
|
| 59 |
+
),
|
| 60 |
"kind": self.kind,
|
| 61 |
"mcp_server": self.server_name,
|
| 62 |
"mcp_tool": self.tool_name,
|
|
|
|
| 69 |
|
| 70 |
# ── MCP Client Manager ─────────────────────────────────────────────────
|
| 71 |
|
| 72 |
+
|
| 73 |
class MCPClientManager:
|
| 74 |
"""Manages multiple MCP server connections.
|
| 75 |
|
|
|
|
| 109 |
results[name] = error
|
| 110 |
except Exception as e:
|
| 111 |
results[name] = str(e)
|
| 112 |
+
logger.error("MCP connect failed for %s: %s", name, e)
|
| 113 |
return results
|
| 114 |
|
| 115 |
def disconnect_all(self) -> None:
|
|
|
|
| 125 |
"""Get a specific MCP tool by its full name (mcp_server_tool)."""
|
| 126 |
return self._tools.get(name)
|
| 127 |
|
| 128 |
+
def call_tool(
|
| 129 |
+
self, server_name: str, tool_name: str, arguments: dict[str, Any]
|
| 130 |
+
) -> dict[str, Any]:
|
| 131 |
"""Call an MCP tool on a specific server."""
|
| 132 |
client = self._clients.get(server_name)
|
| 133 |
if not client:
|
|
|
|
| 168 |
if client and hasattr(client, "list_resources"):
|
| 169 |
try:
|
| 170 |
result = client.list_resources()
|
| 171 |
+
for r in result.resources if hasattr(result, "resources") else []:
|
| 172 |
+
resources.append(
|
| 173 |
+
{
|
| 174 |
+
"server": name,
|
| 175 |
+
"uri": getattr(r, "uri", str(r)),
|
| 176 |
+
"name": getattr(r, "name", ""),
|
| 177 |
+
"description": getattr(r, "description", ""),
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
except Exception as e:
|
| 181 |
+
logger.warning("Failed to list resources from %s: %s", name, e)
|
| 182 |
return resources
|
| 183 |
|
| 184 |
def get_status(self) -> dict[str, Any]:
|
|
|
|
| 189 |
"enabled": config.enabled,
|
| 190 |
"connected": name in self._connected,
|
| 191 |
"transport": config.transport,
|
| 192 |
+
"tool_count": sum(
|
| 193 |
+
1 for t in self._tools.values() if t.server_name == name
|
| 194 |
+
),
|
| 195 |
}
|
| 196 |
return status
|
| 197 |
|
|
|
|
| 205 |
try:
|
| 206 |
# Try to import MCP SDK
|
| 207 |
try:
|
| 208 |
+
from mcp import ClientSession, StdioServerParameters # noqa: F401
|
| 209 |
+
from mcp.client.stdio import stdio_client # noqa: F401
|
| 210 |
except ImportError:
|
| 211 |
logger.info("MCP SDK not installed. Skipping MCP server %s", name)
|
| 212 |
return "mcp SDK not installed (pip install mcp)"
|
|
|
|
| 228 |
|
| 229 |
# Discover tools
|
| 230 |
self._discover_tools(name, client_session)
|
| 231 |
+
logger.info(
|
| 232 |
+
"MCP server '%s' connected with %d tools",
|
| 233 |
+
name,
|
| 234 |
+
sum(1 for t in self._tools.values() if t.server_name == name),
|
| 235 |
+
)
|
| 236 |
return None
|
| 237 |
|
| 238 |
elif config.transport == "sse":
|
|
|
|
| 241 |
return f"Unknown transport: {config.transport}"
|
| 242 |
|
| 243 |
except Exception as e:
|
| 244 |
+
logger.error("MCP connect error for %s: %s", name, e)
|
| 245 |
return str(e)
|
| 246 |
|
| 247 |
def _run_async_connect(self, name: str, server_params):
|
| 248 |
"""Run async MCP connection in a sync context."""
|
| 249 |
+
from mcp import ClientSession
|
| 250 |
from mcp.client.stdio import stdio_client
|
| 251 |
|
| 252 |
result = {"session": None, "error": None}
|
|
|
|
| 263 |
result["error"] = str(e)
|
| 264 |
|
| 265 |
loop = asyncio.new_event_loop()
|
| 266 |
+
thread = threading.Thread(
|
| 267 |
+
target=loop.run_until_complete, args=(_connect_async(),), daemon=True
|
| 268 |
+
)
|
| 269 |
thread.start()
|
| 270 |
thread.join(timeout=10)
|
| 271 |
|
|
|
|
| 291 |
for tool in tools:
|
| 292 |
schema = {
|
| 293 |
"description": getattr(tool, "description", ""),
|
| 294 |
+
"inputSchema": getattr(
|
| 295 |
+
tool, "inputSchema", {"type": "object", "properties": {}}
|
| 296 |
+
),
|
| 297 |
"annotations": getattr(tool, "annotations", {}),
|
| 298 |
}
|
| 299 |
tool_name = getattr(tool, "name", "unknown")
|
|
|
|
| 302 |
server_name=server_name,
|
| 303 |
tool_name=tool_name,
|
| 304 |
tool_schema=schema,
|
| 305 |
+
call_fn=lambda sn=server_name, tn=tool_name, args=None:
|
| 306 |
+
self.call_tool(sn, tn, args or {}),
|
| 307 |
)
|
| 308 |
self._tools[bridge.name] = bridge
|
| 309 |
+
logger.debug("Discovered MCP tool: %s", bridge.name)
|
| 310 |
|
| 311 |
except Exception as e:
|
| 312 |
+
logger.warning("Failed to discover tools from %s: %s", server_name, e)
|
| 313 |
|
| 314 |
def _disconnect(self, name: str) -> None:
|
| 315 |
"""Disconnect from an MCP server."""
|
|
|
|
| 331 |
def __getattr__(self, name):
|
| 332 |
attr = getattr(self._session, name)
|
| 333 |
if asyncio.iscoroutinefunction(attr):
|
| 334 |
+
|
| 335 |
def sync_wrapper(*args, **kwargs):
|
| 336 |
try:
|
| 337 |
loop = asyncio.get_event_loop()
|
| 338 |
if loop.is_running():
|
| 339 |
# Can't await in running loop, return a placeholder
|
| 340 |
import concurrent.futures
|
| 341 |
+
|
| 342 |
with concurrent.futures.ThreadPoolExecutor() as pool:
|
| 343 |
future = pool.submit(asyncio.run, attr(*args, **kwargs))
|
| 344 |
return future.result(timeout=30)
|
|
|
|
| 346 |
return loop.run_until_complete(attr(*args, **kwargs))
|
| 347 |
except RuntimeError:
|
| 348 |
return asyncio.run(attr(*args, **kwargs))
|
| 349 |
+
|
| 350 |
return sync_wrapper
|
| 351 |
return attr
|
| 352 |
|
|
|
|
| 361 |
global _global_manager
|
| 362 |
if _global_manager is None:
|
| 363 |
_global_manager = MCPClientManager()
|
| 364 |
+
return _global_manager
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
__all__ = ["MCPToolBridge", "MCPClientManager", "get_mcp_manager"]
|
code/model/loader.py
CHANGED
|
@@ -170,7 +170,7 @@ def start_background_load(model_key: str | None = None) -> threading.Thread:
|
|
| 170 |
|
| 171 |
def switch_model(model_key: str) -> dict[str, Any]:
|
| 172 |
"""Switch to a different model. Returns status immediately, loads in background."""
|
| 173 |
-
global _current_model_key
|
| 174 |
|
| 175 |
if model_key not in MODEL_CONFIGS:
|
| 176 |
return {"success": False, "message": f"Unknown model: {model_key}"}
|
|
|
|
| 170 |
|
| 171 |
def switch_model(model_key: str) -> dict[str, Any]:
|
| 172 |
"""Switch to a different model. Returns status immediately, loads in background."""
|
| 173 |
+
global _current_model_key, _model_loaded
|
| 174 |
|
| 175 |
if model_key not in MODEL_CONFIGS:
|
| 176 |
return {"success": False, "message": f"Unknown model: {model_key}"}
|
{sonicoder → code}/policy/__init__.py
RENAMED
|
@@ -6,7 +6,6 @@ and pattern matching. Also incorporates Claude Code's allow/ask/deny model.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
import fnmatch
|
| 10 |
import re
|
| 11 |
from dataclasses import dataclass
|
| 12 |
from enum import Enum
|
|
@@ -22,6 +21,7 @@ class ApprovalDecision(Enum):
|
|
| 22 |
@dataclass
|
| 23 |
class PolicyCheckResult:
|
| 24 |
"""Result of a policy check."""
|
|
|
|
| 25 |
decision: ApprovalDecision
|
| 26 |
reason: str = ""
|
| 27 |
matched_rule: str | None = None
|
|
@@ -79,6 +79,13 @@ class PolicyEngine:
|
|
| 79 |
"priority": -100,
|
| 80 |
"description": "Allow file edits in sandboxed workspace",
|
| 81 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
{
|
| 83 |
"tool_name": "read_file",
|
| 84 |
"args_pattern": None,
|
|
@@ -122,11 +129,25 @@ class PolicyEngine:
|
|
| 122 |
"description": "Allow web fetch",
|
| 123 |
},
|
| 124 |
{
|
| 125 |
-
"tool_name": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
"args_pattern": None,
|
| 127 |
"approval": "allow",
|
| 128 |
"priority": -200,
|
| 129 |
-
"description": "Allow todo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
},
|
| 131 |
{
|
| 132 |
"tool_name": "snapshot_workspace",
|
|
@@ -172,7 +193,9 @@ class PolicyEngine:
|
|
| 172 |
for r in rules:
|
| 173 |
self.add_rule(r)
|
| 174 |
|
| 175 |
-
def check(
|
|
|
|
|
|
|
| 176 |
"""Check if a tool call is allowed.
|
| 177 |
|
| 178 |
Returns the decision and reason from the highest-priority matching rule.
|
|
@@ -201,12 +224,17 @@ class PolicyEngine:
|
|
| 201 |
approval = "ask"
|
| 202 |
|
| 203 |
# Auto-edit mode: auto-approve file writes/edits
|
| 204 |
-
if
|
|
|
|
|
|
|
|
|
|
| 205 |
approval = "allow"
|
| 206 |
|
| 207 |
return PolicyCheckResult(
|
| 208 |
decision=ApprovalDecision(approval),
|
| 209 |
-
reason=rule.get(
|
|
|
|
|
|
|
| 210 |
matched_rule=rule.get("description"),
|
| 211 |
)
|
| 212 |
|
|
@@ -242,4 +270,12 @@ def get_policy_engine() -> PolicyEngine:
|
|
| 242 |
global _global_engine
|
| 243 |
if _global_engine is None:
|
| 244 |
_global_engine = PolicyEngine()
|
| 245 |
-
return _global_engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import re
|
| 10 |
from dataclasses import dataclass
|
| 11 |
from enum import Enum
|
|
|
|
| 21 |
@dataclass
|
| 22 |
class PolicyCheckResult:
|
| 23 |
"""Result of a policy check."""
|
| 24 |
+
|
| 25 |
decision: ApprovalDecision
|
| 26 |
reason: str = ""
|
| 27 |
matched_rule: str | None = None
|
|
|
|
| 79 |
"priority": -100,
|
| 80 |
"description": "Allow file edits in sandboxed workspace",
|
| 81 |
},
|
| 82 |
+
{
|
| 83 |
+
"tool_name": "multi_edit",
|
| 84 |
+
"args_pattern": None,
|
| 85 |
+
"approval": "allow",
|
| 86 |
+
"priority": -100,
|
| 87 |
+
"description": "Allow multi-file edits in sandboxed workspace",
|
| 88 |
+
},
|
| 89 |
{
|
| 90 |
"tool_name": "read_file",
|
| 91 |
"args_pattern": None,
|
|
|
|
| 129 |
"description": "Allow web fetch",
|
| 130 |
},
|
| 131 |
{
|
| 132 |
+
"tool_name": "todo_read",
|
| 133 |
+
"args_pattern": None,
|
| 134 |
+
"approval": "allow",
|
| 135 |
+
"priority": -200,
|
| 136 |
+
"description": "Allow todo reads",
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"tool_name": "todo_write",
|
| 140 |
"args_pattern": None,
|
| 141 |
"approval": "allow",
|
| 142 |
"priority": -200,
|
| 143 |
+
"description": "Allow todo writes",
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"tool_name": "todo_update",
|
| 147 |
+
"args_pattern": None,
|
| 148 |
+
"approval": "allow",
|
| 149 |
+
"priority": -200,
|
| 150 |
+
"description": "Allow todo updates",
|
| 151 |
},
|
| 152 |
{
|
| 153 |
"tool_name": "snapshot_workspace",
|
|
|
|
| 193 |
for r in rules:
|
| 194 |
self.add_rule(r)
|
| 195 |
|
| 196 |
+
def check(
|
| 197 |
+
self, tool_name: str, args: dict[str, Any] | None = None
|
| 198 |
+
) -> PolicyCheckResult:
|
| 199 |
"""Check if a tool call is allowed.
|
| 200 |
|
| 201 |
Returns the decision and reason from the highest-priority matching rule.
|
|
|
|
| 224 |
approval = "ask"
|
| 225 |
|
| 226 |
# Auto-edit mode: auto-approve file writes/edits
|
| 227 |
+
if (
|
| 228 |
+
self._approval_mode == "auto_edit"
|
| 229 |
+
and tool_name in ("write_file", "edit_file", "multi_edit")
|
| 230 |
+
):
|
| 231 |
approval = "allow"
|
| 232 |
|
| 233 |
return PolicyCheckResult(
|
| 234 |
decision=ApprovalDecision(approval),
|
| 235 |
+
reason=rule.get(
|
| 236 |
+
"description", f"Rule matched: {rule['tool_name']}"
|
| 237 |
+
),
|
| 238 |
matched_rule=rule.get("description"),
|
| 239 |
)
|
| 240 |
|
|
|
|
| 270 |
global _global_engine
|
| 271 |
if _global_engine is None:
|
| 272 |
_global_engine = PolicyEngine()
|
| 273 |
+
return _global_engine
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
__all__ = [
|
| 277 |
+
"ApprovalDecision",
|
| 278 |
+
"PolicyCheckResult",
|
| 279 |
+
"PolicyEngine",
|
| 280 |
+
"get_policy_engine",
|
| 281 |
+
]
|
code/server/__init__.py
CHANGED
|
@@ -1 +1,31 @@
|
|
| 1 |
-
"""FastAPI / Gradio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI / Gradio Server entry point.
|
| 2 |
+
|
| 3 |
+
Provides `create_app()` which returns the configured `gradio.Server` instance
|
| 4 |
+
with all HTTP routes and Gradio API endpoints registered.
|
| 5 |
+
|
| 6 |
+
The actual routes are defined in `code/server/routes.py`.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_app() -> Any:
|
| 15 |
+
"""Create and return the configured Gradio Server app instance.
|
| 16 |
+
|
| 17 |
+
This is a thin wrapper around `code.server.routes.get_app()` that
|
| 18 |
+
triggers all the route decorators when the routes module is first
|
| 19 |
+
imported.
|
| 20 |
+
"""
|
| 21 |
+
from code.server.routes import get_app # noqa: F401 (import has side effects)
|
| 22 |
+
|
| 23 |
+
return get_app()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_app() -> Any:
|
| 27 |
+
"""Backward-compatible alias for create_app()."""
|
| 28 |
+
return create_app()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
__all__ = ["create_app", "get_app"]
|
code/server/routes.py
CHANGED
|
@@ -33,11 +33,13 @@ import json
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
import tempfile
|
|
|
|
| 36 |
from pathlib import Path
|
| 37 |
from typing import Any, Optional
|
| 38 |
|
| 39 |
import gradio as gr
|
| 40 |
-
from fastapi
|
|
|
|
| 41 |
try:
|
| 42 |
from gradio import Server
|
| 43 |
except ImportError:
|
|
@@ -717,6 +719,416 @@ def get_app() -> Server:
|
|
| 717 |
return app
|
| 718 |
|
| 719 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
# ─── Agent / Skills / Commands / Hooks / Workspace Endpoints ──────────
|
| 721 |
|
| 722 |
|
|
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
import tempfile
|
| 36 |
+
import uuid
|
| 37 |
from pathlib import Path
|
| 38 |
from typing import Any, Optional
|
| 39 |
|
| 40 |
import gradio as gr
|
| 41 |
+
from fastapi import Request
|
| 42 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse
|
| 43 |
try:
|
| 44 |
from gradio import Server
|
| 45 |
except ImportError:
|
|
|
|
| 719 |
return app
|
| 720 |
|
| 721 |
|
| 722 |
+
# ─── V2 REST Endpoints (used by root index.html) ──────────────────────
|
| 723 |
+
# These are plain FastAPI routes that the v2 frontend (index.html at project
|
| 724 |
+
# root) calls with `fetch()`. They live alongside the @app.api Gradio
|
| 725 |
+
# endpoints above; the two API styles don't conflict because they use
|
| 726 |
+
# different URL patterns (/api/<name> for Gradio, /api/<name> for REST
|
| 727 |
+
# with different methods / payloads).
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def _json_response(data: Any) -> JSONResponse:
|
| 731 |
+
"""JSONResponse that handles non-serializable objects via str() fallback."""
|
| 732 |
+
return JSONResponse(content=json.loads(json.dumps(data, default=str)))
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
# ─── Session state for v2 streaming chat ──────────────────────────────
|
| 736 |
+
|
| 737 |
+
_v2_sessions: dict[str, dict[str, Any]] = {}
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def _v2_get_or_create_session(session_id: str = "") -> tuple[str, dict[str, Any]]:
|
| 741 |
+
"""Get or create a per-session state dict (chat history + agent context)."""
|
| 742 |
+
if not session_id:
|
| 743 |
+
session_id = str(uuid.uuid4())[:12]
|
| 744 |
+
if session_id not in _v2_sessions:
|
| 745 |
+
_v2_sessions[session_id] = {
|
| 746 |
+
"history": [],
|
| 747 |
+
"active_agent": None,
|
| 748 |
+
}
|
| 749 |
+
return session_id, _v2_sessions[session_id]
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
@app.get("/api/get_config")
|
| 753 |
+
async def v2_get_config():
|
| 754 |
+
"""Return runtime config: models, default model, workspace, version."""
|
| 755 |
+
models = []
|
| 756 |
+
for key, cfg in MODEL_CONFIGS.items():
|
| 757 |
+
models.append({
|
| 758 |
+
"key": key,
|
| 759 |
+
"name": cfg["name"],
|
| 760 |
+
"model_type": cfg["type"],
|
| 761 |
+
"size_gb": cfg.get("size_gb", 0),
|
| 762 |
+
"description": cfg.get("description", ""),
|
| 763 |
+
})
|
| 764 |
+
|
| 765 |
+
# Best-effort load of skills / commands / hooks / agents
|
| 766 |
+
skills_list: list[Any] = []
|
| 767 |
+
commands_list: list[Any] = []
|
| 768 |
+
hooks_list: list[Any] = []
|
| 769 |
+
agents_list: list[Any] = []
|
| 770 |
+
active_agent: Any = None
|
| 771 |
+
|
| 772 |
+
try:
|
| 773 |
+
from code.skills import list_skills
|
| 774 |
+
skills_list = list_skills()
|
| 775 |
+
except Exception as exc:
|
| 776 |
+
logger.debug("list_skills failed: %s", exc)
|
| 777 |
+
|
| 778 |
+
try:
|
| 779 |
+
from code.commands import list_commands
|
| 780 |
+
commands_list = list_commands()
|
| 781 |
+
except Exception as exc:
|
| 782 |
+
logger.debug("list_commands failed: %s", exc)
|
| 783 |
+
|
| 784 |
+
try:
|
| 785 |
+
from code.hooks import list_hooks
|
| 786 |
+
hooks_list = list_hooks()
|
| 787 |
+
except Exception as exc:
|
| 788 |
+
logger.debug("list_hooks failed: %s", exc)
|
| 789 |
+
|
| 790 |
+
try:
|
| 791 |
+
from code.agents import list_agents, get_active_agent
|
| 792 |
+
agents_list = list_agents()
|
| 793 |
+
active_agent = get_active_agent()
|
| 794 |
+
except Exception as exc:
|
| 795 |
+
logger.debug("list_agents failed: %s", exc)
|
| 796 |
+
|
| 797 |
+
return JSONResponse(content={
|
| 798 |
+
"app_title": APP_TITLE,
|
| 799 |
+
"models": models,
|
| 800 |
+
"model_configs": {
|
| 801 |
+
k: {
|
| 802 |
+
"name": v["name"],
|
| 803 |
+
"type": v["type"],
|
| 804 |
+
"description": v.get("description", ""),
|
| 805 |
+
}
|
| 806 |
+
for k, v in MODEL_CONFIGS.items()
|
| 807 |
+
},
|
| 808 |
+
"default_model": DEFAULT_MODEL_KEY,
|
| 809 |
+
"model_url": MODEL_URL,
|
| 810 |
+
"languages": LANGUAGE_OPTIONS,
|
| 811 |
+
"examples": [
|
| 812 |
+
{"label": label, "prompt": prompt, "language": lang, "framework": fw}
|
| 813 |
+
for label, prompt, lang, fw in EXAMPLE_PROMPTS
|
| 814 |
+
],
|
| 815 |
+
"skills": skills_list,
|
| 816 |
+
"commands": commands_list,
|
| 817 |
+
"hooks": hooks_list,
|
| 818 |
+
"agents": agents_list,
|
| 819 |
+
"active_agent": active_agent,
|
| 820 |
+
"workspace": str(_get_workspace_root()),
|
| 821 |
+
"version": "2.0.0",
|
| 822 |
+
})
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
def _get_workspace_root() -> Path:
|
| 826 |
+
"""Return the workspace root path (created on demand)."""
|
| 827 |
+
try:
|
| 828 |
+
from code.tools.fs import get_workspace_root
|
| 829 |
+
return Path(get_workspace_root())
|
| 830 |
+
except Exception:
|
| 831 |
+
from code.config import WORKSPACE_ROOT
|
| 832 |
+
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 833 |
+
return WORKSPACE_ROOT
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
@app.get("/api/model_status")
|
| 837 |
+
async def v2_model_status():
|
| 838 |
+
"""Return current model loading status (v2 underscore-style)."""
|
| 839 |
+
return JSONResponse(content=get_model_status())
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
@app.get("/api/list_models")
|
| 843 |
+
async def v2_list_models():
|
| 844 |
+
"""List all available models with the `current` flag set."""
|
| 845 |
+
current = get_current_model_key()
|
| 846 |
+
result = []
|
| 847 |
+
for key, cfg in MODEL_CONFIGS.items():
|
| 848 |
+
result.append({
|
| 849 |
+
"key": key,
|
| 850 |
+
"name": cfg["name"],
|
| 851 |
+
"model_type": cfg["type"],
|
| 852 |
+
"size_gb": cfg.get("size_gb", 0),
|
| 853 |
+
"description": cfg.get("description", ""),
|
| 854 |
+
"current": key == current,
|
| 855 |
+
})
|
| 856 |
+
return JSONResponse(content=result)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
@app.post("/api/switch_model")
|
| 860 |
+
async def v2_switch_model(request: Request):
|
| 861 |
+
"""Switch to a different model. Accepts JSON: {model_key: str}."""
|
| 862 |
+
try:
|
| 863 |
+
body = await request.json()
|
| 864 |
+
except Exception:
|
| 865 |
+
body = {}
|
| 866 |
+
model_key = body.get("model_key", "")
|
| 867 |
+
result = switch_model(model_key)
|
| 868 |
+
return JSONResponse(content={
|
| 869 |
+
"status": "switching" if result.get("success") else "error",
|
| 870 |
+
"model": model_key,
|
| 871 |
+
"message": result.get("message", ""),
|
| 872 |
+
})
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
@app.get("/api/workspace_tree")
|
| 876 |
+
async def v2_workspace_tree(max_depth: int = 4):
|
| 877 |
+
"""Return the workspace file tree as a string."""
|
| 878 |
+
from code.tools.fs import list_workspace_tree
|
| 879 |
+
try:
|
| 880 |
+
result = list_workspace_tree(max_depth=max_depth)
|
| 881 |
+
return _json_response(result)
|
| 882 |
+
except Exception as exc:
|
| 883 |
+
logger.exception("workspace_tree failed")
|
| 884 |
+
return JSONResponse(content={"tree": f"(error: {exc})", "root": str(_get_workspace_root())})
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
@app.post("/api/workspace_read")
|
| 888 |
+
async def v2_workspace_read(request: Request):
|
| 889 |
+
"""Read a file from the workspace."""
|
| 890 |
+
try:
|
| 891 |
+
body = await request.json()
|
| 892 |
+
except Exception:
|
| 893 |
+
body = {}
|
| 894 |
+
path = body.get("path", "")
|
| 895 |
+
offset = int(body.get("offset", 1) or 1)
|
| 896 |
+
limit = int(body.get("limit", 500) or 500)
|
| 897 |
+
from code.tools.fs import read_file
|
| 898 |
+
result = read_file(path=path, offset=offset, limit=limit)
|
| 899 |
+
return _json_response(result)
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
@app.post("/api/workspace_write")
|
| 903 |
+
async def v2_workspace_write(request: Request):
|
| 904 |
+
"""Write a file in the workspace."""
|
| 905 |
+
try:
|
| 906 |
+
body = await request.json()
|
| 907 |
+
except Exception:
|
| 908 |
+
body = {}
|
| 909 |
+
path = body.get("path", "")
|
| 910 |
+
content = body.get("content", "")
|
| 911 |
+
from code.tools.fs import write_file
|
| 912 |
+
result = write_file(path=path, content=content)
|
| 913 |
+
return _json_response(result)
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
@app.post("/api/workspace_bash")
|
| 917 |
+
async def v2_workspace_bash(request: Request):
|
| 918 |
+
"""Run a bash command in the workspace."""
|
| 919 |
+
try:
|
| 920 |
+
body = await request.json()
|
| 921 |
+
except Exception:
|
| 922 |
+
body = {}
|
| 923 |
+
command = body.get("command", "")
|
| 924 |
+
timeout = int(body.get("timeout", 30) or 30)
|
| 925 |
+
from code.tools.bash import run_bash
|
| 926 |
+
result = run_bash(command=command, timeout=timeout)
|
| 927 |
+
return _json_response(result)
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
@app.get("/api/mcp_status")
|
| 931 |
+
async def v2_mcp_status():
|
| 932 |
+
"""Return MCP server connection status."""
|
| 933 |
+
try:
|
| 934 |
+
from code.mcp import get_mcp_manager
|
| 935 |
+
return JSONResponse(content=get_mcp_manager().get_status())
|
| 936 |
+
except Exception as exc:
|
| 937 |
+
logger.debug("mcp_status failed: %s", exc)
|
| 938 |
+
return JSONResponse(content={})
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
@app.get("/api/policy_status")
|
| 942 |
+
async def v2_policy_status():
|
| 943 |
+
"""Return policy engine mode and rules."""
|
| 944 |
+
try:
|
| 945 |
+
from code.policy import get_policy_engine
|
| 946 |
+
engine = get_policy_engine()
|
| 947 |
+
return JSONResponse(content={
|
| 948 |
+
"mode": engine.approval_mode,
|
| 949 |
+
"rules": engine.get_rules(),
|
| 950 |
+
})
|
| 951 |
+
except Exception as exc:
|
| 952 |
+
logger.debug("policy_status failed: %s", exc)
|
| 953 |
+
return JSONResponse(content={"mode": "default", "rules": []})
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
@app.post("/api/reset_session")
|
| 957 |
+
async def v2_reset_session(request: Request):
|
| 958 |
+
"""Clear a chat session's history."""
|
| 959 |
+
try:
|
| 960 |
+
body = await request.json()
|
| 961 |
+
except Exception:
|
| 962 |
+
body = {}
|
| 963 |
+
session_id = body.get("session_id", "")
|
| 964 |
+
if session_id in _v2_sessions:
|
| 965 |
+
_v2_sessions[session_id]["history"] = []
|
| 966 |
+
_v2_sessions[session_id]["active_agent"] = None
|
| 967 |
+
return JSONResponse(content={"success": True})
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
@app.post("/api/list_skills")
|
| 971 |
+
async def v2_list_skills():
|
| 972 |
+
"""List all available skills."""
|
| 973 |
+
try:
|
| 974 |
+
from code.skills import list_skills
|
| 975 |
+
return JSONResponse(content={"success": True, "skills": list_skills()})
|
| 976 |
+
except Exception as exc:
|
| 977 |
+
return JSONResponse(content={"success": False, "skills": [], "error": str(exc)})
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
@app.post("/api/list_commands")
|
| 981 |
+
async def v2_list_commands():
|
| 982 |
+
"""List all available slash commands."""
|
| 983 |
+
try:
|
| 984 |
+
from code.commands import list_commands
|
| 985 |
+
return JSONResponse(content={"success": True, "commands": list_commands()})
|
| 986 |
+
except Exception as exc:
|
| 987 |
+
return JSONResponse(content={"success": False, "commands": [], "error": str(exc)})
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
@app.post("/api/list_hooks")
|
| 991 |
+
async def v2_list_hooks():
|
| 992 |
+
"""List all configured hooks."""
|
| 993 |
+
try:
|
| 994 |
+
from code.hooks import list_hooks
|
| 995 |
+
return JSONResponse(content={"success": True, "hooks": list_hooks()})
|
| 996 |
+
except Exception as exc:
|
| 997 |
+
return JSONResponse(content={"success": False, "hooks": [], "error": str(exc)})
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
@app.post("/api/list_agents")
|
| 1001 |
+
async def v2_list_agents():
|
| 1002 |
+
"""List all available agents (builtins + user) and the active one."""
|
| 1003 |
+
try:
|
| 1004 |
+
from code.agents import list_agents, get_active_agent
|
| 1005 |
+
return _json_response({
|
| 1006 |
+
"success": True,
|
| 1007 |
+
"agents": list_agents(),
|
| 1008 |
+
"active_agent": get_active_agent(),
|
| 1009 |
+
})
|
| 1010 |
+
except Exception as exc:
|
| 1011 |
+
return JSONResponse(content={"success": False, "agents": [], "error": str(exc)})
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
@app.post("/api/set_active_agent")
|
| 1015 |
+
async def v2_set_active_agent(request: Request):
|
| 1016 |
+
"""Set or clear the active agent for subsequent prompts."""
|
| 1017 |
+
try:
|
| 1018 |
+
body = await request.json()
|
| 1019 |
+
except Exception:
|
| 1020 |
+
body = {}
|
| 1021 |
+
name = body.get("name", "")
|
| 1022 |
+
try:
|
| 1023 |
+
from code.agents import set_active_agent
|
| 1024 |
+
result = set_active_agent(name or None)
|
| 1025 |
+
return _json_response(result)
|
| 1026 |
+
except Exception as exc:
|
| 1027 |
+
return JSONResponse(content={"success": False, "error": str(exc)})
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
@app.post("/api/todo_read")
|
| 1031 |
+
async def v2_todo_read(request: Request):
|
| 1032 |
+
"""Read the current todo list."""
|
| 1033 |
+
try:
|
| 1034 |
+
body = await request.json()
|
| 1035 |
+
except Exception:
|
| 1036 |
+
body = {}
|
| 1037 |
+
session_id = body.get("session_id", "default")
|
| 1038 |
+
from code.tools.todos import todo_read
|
| 1039 |
+
return _json_response(todo_read(session_id=session_id))
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
@app.post("/api/todo_write")
|
| 1043 |
+
async def v2_todo_write(request: Request):
|
| 1044 |
+
"""Replace the todo list."""
|
| 1045 |
+
try:
|
| 1046 |
+
body = await request.json()
|
| 1047 |
+
except Exception:
|
| 1048 |
+
body = {}
|
| 1049 |
+
session_id = body.get("session_id", "default")
|
| 1050 |
+
todos = body.get("todos", [])
|
| 1051 |
+
from code.tools.todos import todo_write
|
| 1052 |
+
return _json_response(todo_write(todos=todos, session_id=session_id))
|
| 1053 |
+
|
| 1054 |
+
|
| 1055 |
+
@app.post("/chat")
|
| 1056 |
+
async def v2_chat_stream(request: Request):
|
| 1057 |
+
"""Streaming chat — returns newline-delimited JSON via StreamingResponse.
|
| 1058 |
+
|
| 1059 |
+
Request body: {message, session_id, image_url?, target_language?, target_framework?, search_enabled?, agent_name?}
|
| 1060 |
+
|
| 1061 |
+
This wraps the `run_agent` generator and yields each event as a JSON line.
|
| 1062 |
+
"""
|
| 1063 |
+
try:
|
| 1064 |
+
body = await request.json()
|
| 1065 |
+
except Exception:
|
| 1066 |
+
body = {}
|
| 1067 |
+
|
| 1068 |
+
message = (body.get("message") or "").strip()
|
| 1069 |
+
session_id = body.get("session_id", "")
|
| 1070 |
+
image_url = body.get("image_url", "") or ""
|
| 1071 |
+
target_language = body.get("target_language", "") or ""
|
| 1072 |
+
target_framework = body.get("target_framework", "") or ""
|
| 1073 |
+
search_enabled = str(body.get("search_enabled", "false") or "false").lower() == "true"
|
| 1074 |
+
agent_name = body.get("agent_name", "") or ""
|
| 1075 |
+
|
| 1076 |
+
if not message:
|
| 1077 |
+
return StreamingResponse(
|
| 1078 |
+
iter([json.dumps({"type": "error", "error": "Empty message"}) + "\n"]),
|
| 1079 |
+
media_type="text/event-stream",
|
| 1080 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 1081 |
+
)
|
| 1082 |
+
|
| 1083 |
+
sid, session = _v2_get_or_create_session(session_id)
|
| 1084 |
+
history = session.get("history", [])
|
| 1085 |
+
|
| 1086 |
+
# Run web search if enabled
|
| 1087 |
+
search_context = ""
|
| 1088 |
+
if search_enabled:
|
| 1089 |
+
try:
|
| 1090 |
+
results = web_search_google(message, num_results=6)
|
| 1091 |
+
if results:
|
| 1092 |
+
search_context = format_search_results(results)
|
| 1093 |
+
except Exception as exc:
|
| 1094 |
+
logger.warning("Web search failed: %s", exc)
|
| 1095 |
+
|
| 1096 |
+
from code.agent import run_agent
|
| 1097 |
+
|
| 1098 |
+
def event_generator():
|
| 1099 |
+
try:
|
| 1100 |
+
for event in run_agent(
|
| 1101 |
+
user_input=message,
|
| 1102 |
+
history=history,
|
| 1103 |
+
target_language=target_language,
|
| 1104 |
+
target_framework=target_framework,
|
| 1105 |
+
search_context=search_context,
|
| 1106 |
+
image_url=image_url.strip() or None,
|
| 1107 |
+
agent_name=agent_name.strip() or None,
|
| 1108 |
+
):
|
| 1109 |
+
# Persist final response into session history
|
| 1110 |
+
if event.get("type") == "complete":
|
| 1111 |
+
content = event.get("content", "")
|
| 1112 |
+
session["history"] = history + [
|
| 1113 |
+
{"role": "user", "content": message},
|
| 1114 |
+
{"role": "assistant", "content": content},
|
| 1115 |
+
]
|
| 1116 |
+
yield json.dumps(event, ensure_ascii=False, default=str) + "\n"
|
| 1117 |
+
except Exception as exc:
|
| 1118 |
+
logger.error("Chat stream error: %s", exc, exc_info=True)
|
| 1119 |
+
yield json.dumps({"type": "error", "error": str(exc)}) + "\n"
|
| 1120 |
+
|
| 1121 |
+
return StreamingResponse(
|
| 1122 |
+
event_generator(),
|
| 1123 |
+
media_type="text/event-stream",
|
| 1124 |
+
headers={
|
| 1125 |
+
"Cache-Control": "no-cache",
|
| 1126 |
+
"X-Accel-Buffering": "no",
|
| 1127 |
+
"Connection": "keep-alive",
|
| 1128 |
+
},
|
| 1129 |
+
)
|
| 1130 |
+
|
| 1131 |
+
|
| 1132 |
# ─── Agent / Skills / Commands / Hooks / Workspace Endpoints ──────────
|
| 1133 |
|
| 1134 |
|
index.html
CHANGED
|
@@ -145,6 +145,26 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
|
|
| 145 |
.example-chip{background:rgba(30,42,58,.4);border:1px solid var(--border);border-radius:12px;padding:3px 10px;font-family:var(--font-mono);font-size:11px;color:var(--gray-mid);cursor:pointer;transition:all var(--transition);white-space:nowrap}
|
| 146 |
.example-chip:hover{border-color:var(--purple);color:var(--purple);background:rgba(168,85,247,.05);text-shadow:var(--glow-purple)}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
/* ═══ OUTPUT PANEL ═══ */
|
| 149 |
#output-panel{display:flex;flex-direction:column;width:45%;min-width:340px;max-width:55%;min-height:0;background:var(--bg-panel)}
|
| 150 |
#output-tabs{display:flex;border-bottom:1px solid var(--border);background:rgba(13,17,23,.6);flex-shrink:0}
|
|
@@ -195,46 +215,17 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
|
|
| 195 |
#file-viewer pre{margin:0;font-size:11px;line-height:1.5;color:var(--code-text)}
|
| 196 |
.file-line-num{color:var(--gray-dim);display:inline-block;width:50px;text-align:right;margin-right:12px;user-select:none;border-right:1px solid var(--border);padding-right:8px}
|
| 197 |
|
| 198 |
-
/*
|
| 199 |
-
#pane-mcp{overflow-y:auto
|
| 200 |
-
.mcp-
|
| 201 |
-
.mcp-
|
| 202 |
-
.mcp-
|
| 203 |
-
.mcp-
|
| 204 |
-
.mcp-
|
| 205 |
-
.mcp-
|
| 206 |
-
.mcp-
|
| 207 |
-
.mcp-
|
| 208 |
-
.mcp-
|
| 209 |
-
.mcp-card-info{flex:1;min-width:0}
|
| 210 |
-
.mcp-card-name{font-weight:600;color:var(--gray-light);font-size:12px;margin-bottom:2px}
|
| 211 |
-
.mcp-card-desc{color:var(--gray-dim);font-size:11px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 212 |
-
.mcp-card-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
| 213 |
-
.mcp-card-status{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
| 214 |
-
.mcp-card-status.on{background:var(--success);box-shadow:0 0 6px var(--success)}
|
| 215 |
-
.mcp-card-status.off{background:var(--gray-dim)}
|
| 216 |
-
.mcp-toggle{position:relative;width:34px;height:18px;flex-shrink:0}
|
| 217 |
-
.mcp-toggle input{opacity:0;width:0;height:0;position:absolute}
|
| 218 |
-
.mcp-toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:9px;cursor:pointer;transition:background var(--transition)}
|
| 219 |
-
.mcp-toggle-slider::before{content:'';position:absolute;left:2px;top:2px;width:14px;height:14px;background:var(--gray-dim);border-radius:50%;transition:all var(--transition)}
|
| 220 |
-
.mcp-toggle input:checked+.mcp-toggle-slider{background:rgba(57,255,20,.3);border:1px solid var(--green-dim)}
|
| 221 |
-
.mcp-toggle input:checked+.mcp-toggle-slider::before{transform:translateX(16px);background:var(--green);box-shadow:0 0 6px var(--green)}
|
| 222 |
-
.mcp-card-details{display:none;padding:0 12px 10px;border-top:1px solid var(--border);margin-top:0;padding-top:8px}
|
| 223 |
-
.mcp-card.open .mcp-card-details{display:block}
|
| 224 |
-
.mcp-detail-row{display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;font-size:11px}
|
| 225 |
-
.mcp-detail-label{color:var(--gray-dim);flex-shrink:0;min-width:60px}
|
| 226 |
-
.mcp-detail-value{color:var(--gray-mid);word-break:break-all}
|
| 227 |
-
.mcp-card-tools{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
|
| 228 |
-
.mcp-tool-chip{padding:2px 8px;background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.2);border-radius:10px;font-size:10px;color:var(--cyan)}
|
| 229 |
-
.mcp-card-error{color:var(--red);font-size:11px;margin-top:4px}
|
| 230 |
-
.mcp-card-expand{background:transparent;border:none;color:var(--gray-dim);font-family:var(--font-mono);font-size:10px;cursor:pointer;padding:2px 6px;border-radius:3px;transition:color var(--transition)}
|
| 231 |
-
.mcp-card-expand:hover{color:var(--cyan)}
|
| 232 |
-
.mcp-connecting{color:var(--amber);font-size:10px;animation:pulse 1s ease infinite}
|
| 233 |
-
.mcp-stats-bar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);background:rgba(30,42,58,.3);font-size:11px;flex-shrink:0}
|
| 234 |
-
.mcp-stat{color:var(--gray-dim)}
|
| 235 |
-
.mcp-stat strong{color:var(--gray-light);font-weight:600}
|
| 236 |
-
.mcp-stat .green{color:var(--green)}
|
| 237 |
-
.mcp-stat .red{color:var(--red)}
|
| 238 |
|
| 239 |
/* ═══ STATUS BAR ═══ */
|
| 240 |
#status-bar{display:flex;align-items:center;gap:8px;padding:5px 16px;border-top:1px solid var(--border);background:var(--bg-panel);font-size:11px;flex-shrink:0}
|
|
@@ -290,7 +281,7 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
|
|
| 290 |
<span id="model-pill-text">Loading...</span>
|
| 291 |
</div>
|
| 292 |
<select id="model-select" title="Switch AI model"></select>
|
| 293 |
-
<div class="pill mcp-pill" id="mcp-pill"
|
| 294 |
<span class="dot disconnected" id="mcp-dot"></span>
|
| 295 |
<span id="mcp-pill-text">MCP</span>
|
| 296 |
</div>
|
|
@@ -299,7 +290,7 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
|
|
| 299 |
</header>
|
| 300 |
|
| 301 |
<div id="banner">
|
| 302 |
-
Powered by <a id="banner-model-link" href="https://huggingface.co/
|
| 303 |
</div>
|
| 304 |
|
| 305 |
<div id="main">
|
|
@@ -313,6 +304,42 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
|
|
| 313 |
<button id="btn-send" onclick="handleSend()" title="Send message">➤</button>
|
| 314 |
<button id="btn-stop" onclick="stopGeneration()" title="Stop generation">■ STOP</button>
|
| 315 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
<div id="examples-row"></div>
|
| 317 |
</div>
|
| 318 |
</div>
|
|
@@ -367,25 +394,7 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
|
|
| 367 |
<div id="file-viewer"><pre id="file-content"></pre></div>
|
| 368 |
</div>
|
| 369 |
<div class="tab-pane" id="pane-mcp">
|
| 370 |
-
<div
|
| 371 |
-
<span class="mcp-stat">Servers: <strong id="mcp-total-count">0</strong></span>
|
| 372 |
-
<span class="mcp-stat">Connected: <strong class="green" id="mcp-connected-count">0</strong></span>
|
| 373 |
-
<span class="mcp-stat">Tools: <strong id="mcp-tools-count">0</strong></span>
|
| 374 |
-
</div>
|
| 375 |
-
<div class="mcp-tab-bar" id="mcp-tab-bar">
|
| 376 |
-
<button class="mcp-cat-tab active" data-cat="all" onclick="filterMcpCat('all')">All</button>
|
| 377 |
-
<button class="mcp-cat-tab" data-cat="core" onclick="filterMcpCat('core')">Core</button>
|
| 378 |
-
<button class="mcp-cat-tab" data-cat="reasoning" onclick="filterMcpCat('reasoning')">Reasoning</button>
|
| 379 |
-
<button class="mcp-cat-tab" data-cat="dev-tools" onclick="filterMcpCat('dev-tools')">Dev Tools</button>
|
| 380 |
-
<button class="mcp-cat-tab" data-cat="database" onclick="filterMcpCat('database')">Database</button>
|
| 381 |
-
<button class="mcp-cat-tab" data-cat="search" onclick="filterMcpCat('search')">Search</button>
|
| 382 |
-
<button class="mcp-cat-tab" data-cat="browser" onclick="filterMcpCat('browser')">Browser</button>
|
| 383 |
-
<button class="mcp-cat-tab" data-cat="integrations" onclick="filterMcpCat('integrations')">Integrations</button>
|
| 384 |
-
<button class="mcp-cat-tab" data-cat="utilities" onclick="filterMcpCat('utilities')">Utilities</button>
|
| 385 |
-
</div>
|
| 386 |
-
<div class="mcp-catalog" id="mcp-catalog">
|
| 387 |
-
<div class="mcp-empty" style="color:var(--gray-dim);font-size:12px;text-align:center;padding:40px 20px">Loading MCP catalog...</div>
|
| 388 |
-
</div>
|
| 389 |
</div>
|
| 390 |
</div>
|
| 391 |
</div>
|
|
@@ -414,7 +423,6 @@ var state = {
|
|
| 414 |
isGenerating: false,
|
| 415 |
modelReady: false,
|
| 416 |
activeTab: 'preview',
|
| 417 |
-
activeMcpCat: 'all',
|
| 418 |
toolCalls: {},
|
| 419 |
currentStreamDiv: null,
|
| 420 |
lastCode: '',
|
|
@@ -422,7 +430,8 @@ var state = {
|
|
| 422 |
consoleStdout: '',
|
| 423 |
consoleStderr: '',
|
| 424 |
generatedFiles: [],
|
| 425 |
-
|
|
|
|
| 426 |
};
|
| 427 |
|
| 428 |
// ═══ INIT ═══
|
|
@@ -430,13 +439,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
| 430 |
loadConfig();
|
| 431 |
pollModelStatus();
|
| 432 |
refreshWorkspace();
|
| 433 |
-
|
| 434 |
|
| 435 |
var input = document.getElementById('chat-input');
|
| 436 |
input.addEventListener('input', autoResize);
|
| 437 |
input.addEventListener('keydown', function(e) {
|
| 438 |
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); handleSend(); }
|
| 439 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
});
|
| 441 |
|
| 442 |
function autoResize() {
|
|
@@ -457,9 +475,9 @@ async function loadConfig() {
|
|
| 457 |
try {
|
| 458 |
var resp = await fetch('/api/get_config');
|
| 459 |
var config = await resp.json();
|
|
|
|
| 460 |
var sel = document.getElementById('model-select');
|
| 461 |
sel.innerHTML = '';
|
| 462 |
-
|
| 463 |
(config.models || []).forEach(function(m) {
|
| 464 |
var opt = document.createElement('option');
|
| 465 |
opt.value = m.key;
|
|
@@ -467,12 +485,115 @@ async function loadConfig() {
|
|
| 467 |
if (m.key === config.default_model) opt.selected = true;
|
| 468 |
sel.appendChild(opt);
|
| 469 |
});
|
| 470 |
-
|
| 471 |
sel.addEventListener('change', function() { switchModel(sel.value); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
renderExamples(config);
|
| 473 |
} catch(e) { console.error('Config load failed:', e); }
|
| 474 |
}
|
| 475 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
async function pollModelStatus() {
|
| 477 |
try {
|
| 478 |
var resp = await fetch('/api/model_status');
|
|
@@ -528,150 +649,68 @@ async function refreshWorkspace() {
|
|
| 528 |
} catch(e) { console.error(e); }
|
| 529 |
}
|
| 530 |
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
async function loadMcpCatalog() {
|
| 534 |
try {
|
| 535 |
-
var resp = await fetch('/api/
|
| 536 |
var data = await resp.json();
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
var connected = servers.filter(function(s) { return s.connected; });
|
| 548 |
-
var dot = document.getElementById('mcp-dot');
|
| 549 |
-
var text = document.getElementById('mcp-pill-text');
|
| 550 |
-
dot.className = connected.length > 0 ? 'dot connected' : 'dot disconnected';
|
| 551 |
-
text.textContent = 'MCP ' + (connected.length > 0 ? '(' + connected.length + ')' : '');
|
| 552 |
-
}
|
| 553 |
-
|
| 554 |
-
function filterMcpCat(cat) {
|
| 555 |
-
state.activeMcpCat = cat;
|
| 556 |
-
document.querySelectorAll('.mcp-cat-tab').forEach(function(t) {
|
| 557 |
-
t.classList.toggle('active', t.dataset.cat === cat);
|
| 558 |
-
});
|
| 559 |
-
renderMcpCatalog();
|
| 560 |
-
}
|
| 561 |
-
|
| 562 |
-
function renderMcpCatalog() {
|
| 563 |
-
var cat = state.activeMcpCat;
|
| 564 |
-
var servers = state.mcpServers.filter(function(s) {
|
| 565 |
-
return cat === 'all' || s.category === cat;
|
| 566 |
-
});
|
| 567 |
-
|
| 568 |
-
// Update stats
|
| 569 |
-
var totalServers = state.mcpServers.length;
|
| 570 |
-
var connectedServers = state.mcpServers.filter(function(s) { return s.connected; }).length;
|
| 571 |
-
var totalTools = state.mcpServers.reduce(function(sum, s) { return sum + (s.tool_count || 0); }, 0);
|
| 572 |
-
document.getElementById('mcp-total-count').textContent = totalServers;
|
| 573 |
-
document.getElementById('mcp-connected-count').textContent = connectedServers;
|
| 574 |
-
document.getElementById('mcp-tools-count').textContent = totalTools;
|
| 575 |
-
|
| 576 |
-
if (servers.length === 0) {
|
| 577 |
-
document.getElementById('mcp-catalog').innerHTML = '<div class="mcp-empty">No servers in this category.</div>';
|
| 578 |
-
return;
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
var html = '';
|
| 582 |
-
servers.forEach(function(s) {
|
| 583 |
-
var isOn = s.connected;
|
| 584 |
-
html += '<div class="mcp-card" id="mcp-card-' + escHtml(s.key) + '">';
|
| 585 |
-
html += '<div class="mcp-card-head">';
|
| 586 |
-
html += '<span class="mcp-card-icon">' + (s.icon || '') + '</span>';
|
| 587 |
-
html += '<div class="mcp-card-info">';
|
| 588 |
-
html += '<div class="mcp-card-name">' + escHtml(s.name) + '</div>';
|
| 589 |
-
html += '<div class="mcp-card-desc">' + escHtml(s.description) + '</div>';
|
| 590 |
-
html += '</div>';
|
| 591 |
-
html += '<div class="mcp-card-actions">';
|
| 592 |
-
html += '<span class="mcp-card-status ' + (isOn ? 'on' : 'off') + '" id="mcp-status-' + escHtml(s.key) + '"></span>';
|
| 593 |
-
html += '<label class="mcp-toggle" title="' + (isOn ? 'Disable' : 'Enable') + ' ' + escHtml(s.name) + '">';
|
| 594 |
-
html += '<input type="checkbox" ' + (isOn ? 'checked' : '') + ' onchange="toggleMcpServer(\'' + escHtml(s.key) + '\', this.checked)">';
|
| 595 |
-
html += '<span class="mcp-toggle-slider"></span>';
|
| 596 |
-
html += '</label>';
|
| 597 |
-
html += '<button class="mcp-card-expand" onclick="toggleMcpCard(\'' + escHtml(s.key) + '\')">▼ Info</button>';
|
| 598 |
-
html += '</div>';
|
| 599 |
-
html += '</div>';
|
| 600 |
-
html += '<div class="mcp-card-details" id="mcp-details-' + escHtml(s.key) + '">';
|
| 601 |
-
if (s.install_hint) {
|
| 602 |
-
html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Install:</span><span class="mcp-detail-value" style="color:var(--cyan)">' + escHtml(s.install_hint) + '</span></div>';
|
| 603 |
-
}
|
| 604 |
-
html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Transport:</span><span class="mcp-detail-value">' + escHtml(s.transport) + '</span></div>';
|
| 605 |
-
if (s.command) {
|
| 606 |
-
html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Command:</span><span class="mcp-detail-value">' + escHtml(s.command + ' ' + (s.args || []).join(' ')) + '</span></div>';
|
| 607 |
-
}
|
| 608 |
-
if (s.repo_url) {
|
| 609 |
-
html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Repo:</span><span class="mcp-detail-value"><a href="' + escHtml(s.repo_url) + '" target="_blank" rel="noopener">' + escHtml(s.repo_url) + '</a></span></div>';
|
| 610 |
-
}
|
| 611 |
-
if (s.tool_count > 0) {
|
| 612 |
-
html += '<div class="mcp-card-tools"><span class="mcp-tool-chip">' + s.tool_count + ' tools</span></div>';
|
| 613 |
}
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
try {
|
| 633 |
-
var resp = await fetch('/api/mcp_toggle', {
|
| 634 |
-
method: 'POST',
|
| 635 |
-
headers: {'Content-Type': 'application/json'},
|
| 636 |
-
body: JSON.stringify({ server_key: key, enabled: enabled }),
|
| 637 |
-
});
|
| 638 |
-
var data = await resp.json();
|
| 639 |
-
if (data.error) {
|
| 640 |
-
if (errorEl) errorEl.innerHTML = '<div class="mcp-card-error">' + escHtml(data.error) + '</div>';
|
| 641 |
-
// Revert toggle
|
| 642 |
-
var card = document.getElementById('mcp-card-' + key);
|
| 643 |
-
if (card) {
|
| 644 |
-
var cb = card.querySelector('input[type=checkbox]');
|
| 645 |
-
if (cb) cb.checked = !enabled;
|
| 646 |
}
|
| 647 |
-
|
| 648 |
-
}
|
| 649 |
-
|
| 650 |
-
if (statusEl) statusEl.className = 'mcp-card-status ' + (enabled ? 'on' : 'off');
|
| 651 |
-
// Refresh to get updated tool counts
|
| 652 |
-
setTimeout(loadMcpCatalog, 1500);
|
| 653 |
-
}
|
| 654 |
} catch(e) {
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
if (card) {
|
| 658 |
-
var cb = card.querySelector('input[type=checkbox]');
|
| 659 |
-
if (cb) cb.checked = !enabled;
|
| 660 |
-
}
|
| 661 |
-
if (statusEl) statusEl.className = 'mcp-card-status off';
|
| 662 |
}
|
| 663 |
-
updateMcpPill();
|
| 664 |
}
|
| 665 |
|
| 666 |
function renderExamples(config) {
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
var row = document.getElementById('examples-row');
|
| 676 |
row.innerHTML = '<span class="examples-label">Try:</span>';
|
| 677 |
examples.forEach(function(ex) {
|
|
@@ -680,6 +719,7 @@ function renderExamples(config) {
|
|
| 680 |
chip.textContent = ex;
|
| 681 |
chip.onclick = function() {
|
| 682 |
document.getElementById('chat-input').value = ex;
|
|
|
|
| 683 |
handleSend();
|
| 684 |
};
|
| 685 |
row.appendChild(chip);
|
|
@@ -696,7 +736,6 @@ function switchTab(tab) {
|
|
| 696 |
document.querySelectorAll('.tab-pane').forEach(function(p) {
|
| 697 |
p.classList.toggle('active', p.id === 'pane-' + tab);
|
| 698 |
});
|
| 699 |
-
if (tab === 'mcp') loadMcpCatalog();
|
| 700 |
}
|
| 701 |
|
| 702 |
// ═══ FULLSCREEN ═══
|
|
@@ -732,7 +771,7 @@ function addMessage(role, content, extra) {
|
|
| 732 |
var bodyClass = role === 'assistant' ? '<div class="msg-body">' : '';
|
| 733 |
var bodyClose = role === 'assistant' ? '</div>' : '';
|
| 734 |
var iterationHtml = extra.iteration ? '<div class="iteration-badge">Iteration ' + extra.iteration + '/' + extra.max_iterations + '</div>' : '';
|
| 735 |
-
div.innerHTML = iterationHtml + prefix + '<span class="msg-content">' + renderMd(content) + '</span>' +
|
| 736 |
container.appendChild(div);
|
| 737 |
container.scrollTop = container.scrollHeight;
|
| 738 |
return div;
|
|
@@ -741,6 +780,7 @@ function addMessage(role, content, extra) {
|
|
| 741 |
function renderMd(text) {
|
| 742 |
if (!text) return '';
|
| 743 |
text = escHtml(text);
|
|
|
|
| 744 |
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
|
| 745 |
var id = 'cb_' + Date.now() + Math.random().toString(36).substr(2,4);
|
| 746 |
return '<div class="code-block-wrap"><div class="code-block-header"><span class="code-lang">' + (lang||'code') + '</span><button class="btn-copy" onclick="copyBlock(\'' + id + '\')">📋 Copy</button></div><pre><code id="' + id + '">' + code.trim() + '</code></pre></div>';
|
|
@@ -784,7 +824,6 @@ function addToolCall(toolName, args) {
|
|
| 784 |
var div = document.createElement('div');
|
| 785 |
div.className = 'tool-block open';
|
| 786 |
div.id = id;
|
| 787 |
-
|
| 788 |
var argsStr = '';
|
| 789 |
if (args) {
|
| 790 |
Object.entries(args).forEach(function(e) {
|
|
@@ -792,7 +831,6 @@ function addToolCall(toolName, args) {
|
|
| 792 |
argsStr += escHtml(e[0] + ': ' + v) + '\n';
|
| 793 |
});
|
| 794 |
}
|
| 795 |
-
|
| 796 |
div.innerHTML =
|
| 797 |
'<div class="tool-header">' +
|
| 798 |
'<span class="tool-icon">⚡</span>' +
|
|
@@ -801,7 +839,6 @@ function addToolCall(toolName, args) {
|
|
| 801 |
'</div>' +
|
| 802 |
'<div class="tool-args">' + argsStr + '</div>' +
|
| 803 |
'<div class="tool-result"></div>';
|
| 804 |
-
|
| 805 |
container.appendChild(div);
|
| 806 |
container.scrollTop = container.scrollHeight;
|
| 807 |
state.toolCalls[id] = div;
|
|
@@ -813,7 +850,6 @@ function updateToolCall(id, success, output) {
|
|
| 813 |
if (!div) return;
|
| 814 |
var badge = div.querySelector('.tool-badge');
|
| 815 |
var result = div.querySelector('.tool-result');
|
| 816 |
-
|
| 817 |
if (success) {
|
| 818 |
badge.className = 'tool-badge success';
|
| 819 |
badge.textContent = 'DONE';
|
|
@@ -821,7 +857,6 @@ function updateToolCall(id, success, output) {
|
|
| 821 |
badge.className = 'tool-badge error';
|
| 822 |
badge.textContent = 'ERROR';
|
| 823 |
}
|
| 824 |
-
|
| 825 |
if (output) {
|
| 826 |
result.textContent = output;
|
| 827 |
result.className = success ? 'tool-result' : 'tool-result error-output';
|
|
@@ -868,25 +903,49 @@ async function handleSend() {
|
|
| 868 |
startStreaming();
|
| 869 |
setStatus('status-working', 'GENERATING...');
|
| 870 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
try {
|
| 872 |
var resp = await fetch('/chat', {
|
| 873 |
method: 'POST',
|
| 874 |
headers: {'Content-Type': 'application/json'},
|
| 875 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
});
|
| 877 |
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
| 878 |
-
|
| 879 |
var reader = resp.body.getReader();
|
| 880 |
var decoder = new TextDecoder();
|
| 881 |
var buffer = '';
|
| 882 |
-
|
| 883 |
while (true) {
|
| 884 |
var result = await reader.read();
|
| 885 |
if (result.done) break;
|
| 886 |
buffer += decoder.decode(result.value, {stream: true});
|
| 887 |
var lines = buffer.split('\n');
|
| 888 |
buffer = lines.pop() || '';
|
| 889 |
-
|
| 890 |
for (var i = 0; i < lines.length; i++) {
|
| 891 |
var line = lines[i];
|
| 892 |
if (!line.trim()) continue;
|
|
@@ -909,6 +968,7 @@ async function handleSend() {
|
|
| 909 |
break;
|
| 910 |
}
|
| 911 |
}
|
|
|
|
| 912 |
if (ev.result.output && (ev.result.output.includes('<!DOCTYPE') || ev.result.output.includes('<html'))) {
|
| 913 |
showPreview(ev.result.output);
|
| 914 |
}
|
|
@@ -926,6 +986,12 @@ async function handleSend() {
|
|
| 926 |
if (ev.iterations) setTimeout(refreshWorkspace, 500);
|
| 927 |
setStatus('status-success', 'COMPLETE');
|
| 928 |
setTimeout(function() { if (!state.isGenerating) setStatus('status-idle', 'IDLE'); }, 3000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
break;
|
| 930 |
case 'error':
|
| 931 |
finalizeStream('Error: ' + ev.error);
|
|
@@ -941,7 +1007,6 @@ async function handleSend() {
|
|
| 941 |
setStatus('status-error', 'CONNECTION ERROR');
|
| 942 |
}
|
| 943 |
}
|
| 944 |
-
|
| 945 |
state.isGenerating = false;
|
| 946 |
document.getElementById('btn-send').style.display = 'flex';
|
| 947 |
document.getElementById('btn-stop').style.display = 'none';
|
|
|
|
| 145 |
.example-chip{background:rgba(30,42,58,.4);border:1px solid var(--border);border-radius:12px;padding:3px 10px;font-family:var(--font-mono);font-size:11px;color:var(--gray-mid);cursor:pointer;transition:all var(--transition);white-space:nowrap}
|
| 146 |
.example-chip:hover{border-color:var(--purple);color:var(--purple);background:rgba(168,85,247,.05);text-shadow:var(--glow-purple)}
|
| 147 |
|
| 148 |
+
/* ═══ OPTIONS ROW ═══ */
|
| 149 |
+
#options-row{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}
|
| 150 |
+
.opt-group{display:inline-flex;align-items:center;gap:4px;background:rgba(30,42,58,.3);border:1px solid var(--border);border-radius:var(--radius);padding:2px 4px 2px 6px;transition:border-color var(--transition)}
|
| 151 |
+
.opt-group:hover{border-color:var(--border-focus)}
|
| 152 |
+
.opt-group label{font-size:9px;color:var(--gray-dim);letter-spacing:1px;text-transform:uppercase;flex-shrink:0}
|
| 153 |
+
.opt-group select{background:transparent;border:none;color:var(--cyan);font-family:var(--font-mono);font-size:11px;padding:3px 4px;outline:none;cursor:pointer;max-width:120px}
|
| 154 |
+
.opt-group select option{background:var(--bg-deep);color:var(--gray-light)}
|
| 155 |
+
.opt-toggle{display:inline-flex;align-items:center;gap:5px;background:rgba(30,42,58,.3);border:1px solid var(--border);border-radius:var(--radius);padding:3px 8px;font-size:10px;color:var(--gray-mid);cursor:pointer;transition:all var(--transition);user-select:none;letter-spacing:.5px}
|
| 156 |
+
.opt-toggle:hover{border-color:var(--border-focus);color:var(--gray-light)}
|
| 157 |
+
.opt-toggle input{display:none}
|
| 158 |
+
.opt-toggle .toggle-dot{width:8px;height:8px;border-radius:50%;background:var(--gray-dim);transition:all var(--transition)}
|
| 159 |
+
.opt-toggle.active{color:var(--amber);border-color:rgba(255,179,0,.4);background:rgba(255,179,0,.08);text-shadow:var(--glow-amber)}
|
| 160 |
+
.opt-toggle.active .toggle-dot{background:var(--amber);box-shadow:0 0 6px var(--amber)}
|
| 161 |
+
.opt-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:rgba(30,42,58,.3);border:1px solid var(--border);border-radius:var(--radius);color:var(--gray-mid);cursor:pointer;transition:all var(--transition);font-size:13px;flex-shrink:0}
|
| 162 |
+
.opt-icon-btn:hover{border-color:var(--cyan);color:var(--cyan);text-shadow:var(--glow-cyan)}
|
| 163 |
+
.opt-icon-btn input{display:none}
|
| 164 |
+
.img-preview{display:inline-flex;align-items:center;gap:4px;background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.3);border-radius:var(--radius);padding:2px 6px;font-size:10px;color:var(--cyan);cursor:pointer}
|
| 165 |
+
.img-preview img{width:18px;height:18px;border-radius:2px;object-fit:cover}
|
| 166 |
+
.img-preview .remove{cursor:pointer;color:var(--red);margin-left:2px}
|
| 167 |
+
|
| 168 |
/* ═══ OUTPUT PANEL ═══ */
|
| 169 |
#output-panel{display:flex;flex-direction:column;width:45%;min-width:340px;max-width:55%;min-height:0;background:var(--bg-panel)}
|
| 170 |
#output-tabs{display:flex;border-bottom:1px solid var(--border);background:rgba(13,17,23,.6);flex-shrink:0}
|
|
|
|
| 215 |
#file-viewer pre{margin:0;font-size:11px;line-height:1.5;color:var(--code-text)}
|
| 216 |
.file-line-num{color:var(--gray-dim);display:inline-block;width:50px;text-align:right;margin-right:12px;user-select:none;border-right:1px solid var(--border);padding-right:8px}
|
| 217 |
|
| 218 |
+
/* MCP */
|
| 219 |
+
#pane-mcp{padding:16px;gap:12px;overflow-y:auto}
|
| 220 |
+
.mcp-server{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:8px}
|
| 221 |
+
.mcp-server-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(30,42,58,.5);border-bottom:1px solid var(--border);font-size:12px}
|
| 222 |
+
.mcp-server-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
| 223 |
+
.mcp-server-dot.connected{background:var(--success);box-shadow:0 0 6px var(--success)}
|
| 224 |
+
.mcp-server-dot.disconnected{background:var(--red);box-shadow:0 0 6px var(--red)}
|
| 225 |
+
.mcp-server-name{font-weight:600;color:var(--gray-light);flex:1}
|
| 226 |
+
.mcp-tools{padding:8px 12px;display:flex;flex-wrap:wrap;gap:4px}
|
| 227 |
+
.mcp-tool-tag{padding:2px 8px;background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.2);border-radius:10px;font-size:10px;color:var(--cyan)}
|
| 228 |
+
.mcp-empty{color:var(--gray-dim);font-size:12px;text-align:center;padding:40px 20px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
/* ═══ STATUS BAR ═══ */
|
| 231 |
#status-bar{display:flex;align-items:center;gap:8px;padding:5px 16px;border-top:1px solid var(--border);background:var(--bg-panel);font-size:11px;flex-shrink:0}
|
|
|
|
| 281 |
<span id="model-pill-text">Loading...</span>
|
| 282 |
</div>
|
| 283 |
<select id="model-select" title="Switch AI model"></select>
|
| 284 |
+
<div class="pill mcp-pill" id="mcp-pill">
|
| 285 |
<span class="dot disconnected" id="mcp-dot"></span>
|
| 286 |
<span id="mcp-pill-text">MCP</span>
|
| 287 |
</div>
|
|
|
|
| 290 |
</header>
|
| 291 |
|
| 292 |
<div id="banner">
|
| 293 |
+
Powered by <a id="banner-model-link" href="https://huggingface.co/openbmb/MiniCPM5-1B" target="_blank" rel="noopener"><strong id="banner-model-name">MiniCPM5-1B</strong></a> running locally — no external APIs. Supports MCP tools, multiple 1B models, and agentic code generation with Gemini CLI patterns.
|
| 294 |
</div>
|
| 295 |
|
| 296 |
<div id="main">
|
|
|
|
| 304 |
<button id="btn-send" onclick="handleSend()" title="Send message">➤</button>
|
| 305 |
<button id="btn-stop" onclick="stopGeneration()" title="Stop generation">■ STOP</button>
|
| 306 |
</div>
|
| 307 |
+
<div id="options-row">
|
| 308 |
+
<div class="opt-group">
|
| 309 |
+
<label>Lang</label>
|
| 310 |
+
<select id="opt-language" onchange="updateFrameworkOptions()" title="Target language">
|
| 311 |
+
<option value="">Auto</option>
|
| 312 |
+
</select>
|
| 313 |
+
</div>
|
| 314 |
+
<div class="opt-group">
|
| 315 |
+
<label>FW</label>
|
| 316 |
+
<select id="opt-framework" title="Target framework">
|
| 317 |
+
<option value="">Auto</option>
|
| 318 |
+
</select>
|
| 319 |
+
</div>
|
| 320 |
+
<div class="opt-group">
|
| 321 |
+
<label>Skill</label>
|
| 322 |
+
<select id="opt-skill" title="Apply a skill (use /skill command for multiple)">
|
| 323 |
+
<option value="">None</option>
|
| 324 |
+
</select>
|
| 325 |
+
</div>
|
| 326 |
+
<div class="opt-group">
|
| 327 |
+
<label>Agent</label>
|
| 328 |
+
<select id="opt-agent" title="Activate a custom agent persona" onchange="setActiveAgent(this.value)">
|
| 329 |
+
<option value="">Default</option>
|
| 330 |
+
</select>
|
| 331 |
+
</div>
|
| 332 |
+
<label class="opt-toggle" id="opt-search-toggle" title="Search the web before generating">
|
| 333 |
+
<input type="checkbox" id="opt-search">
|
| 334 |
+
<span class="toggle-dot"></span>
|
| 335 |
+
<span>Web</span>
|
| 336 |
+
</label>
|
| 337 |
+
<label class="opt-icon-btn" title="Attach image (for VLM models)">
|
| 338 |
+
<input type="file" id="opt-image-input" accept="image/*" onchange="handleImageSelect(event)">
|
| 339 |
+
<span>📷</span>
|
| 340 |
+
</label>
|
| 341 |
+
<span id="image-preview-container"></span>
|
| 342 |
+
</div>
|
| 343 |
<div id="examples-row"></div>
|
| 344 |
</div>
|
| 345 |
</div>
|
|
|
|
| 394 |
<div id="file-viewer"><pre id="file-content"></pre></div>
|
| 395 |
</div>
|
| 396 |
<div class="tab-pane" id="pane-mcp">
|
| 397 |
+
<div id="mcp-content"><div class="mcp-empty">Loading MCP status...</div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
</div>
|
| 399 |
</div>
|
| 400 |
</div>
|
|
|
|
| 423 |
isGenerating: false,
|
| 424 |
modelReady: false,
|
| 425 |
activeTab: 'preview',
|
|
|
|
| 426 |
toolCalls: {},
|
| 427 |
currentStreamDiv: null,
|
| 428 |
lastCode: '',
|
|
|
|
| 430 |
consoleStdout: '',
|
| 431 |
consoleStderr: '',
|
| 432 |
generatedFiles: [],
|
| 433 |
+
attachedImage: null, // {dataUrl, file_url} or null
|
| 434 |
+
config: null, // last loaded runtime config
|
| 435 |
};
|
| 436 |
|
| 437 |
// ═══ INIT ═══
|
|
|
|
| 439 |
loadConfig();
|
| 440 |
pollModelStatus();
|
| 441 |
refreshWorkspace();
|
| 442 |
+
loadMcpStatus();
|
| 443 |
|
| 444 |
var input = document.getElementById('chat-input');
|
| 445 |
input.addEventListener('input', autoResize);
|
| 446 |
input.addEventListener('keydown', function(e) {
|
| 447 |
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); handleSend(); }
|
| 448 |
});
|
| 449 |
+
|
| 450 |
+
// Wire up the Web search toggle
|
| 451 |
+
var searchToggle = document.getElementById('opt-search-toggle');
|
| 452 |
+
searchToggle.addEventListener('click', function(e) {
|
| 453 |
+
e.preventDefault();
|
| 454 |
+
var cb = document.getElementById('opt-search');
|
| 455 |
+
cb.checked = !cb.checked;
|
| 456 |
+
searchToggle.classList.toggle('active', cb.checked);
|
| 457 |
+
});
|
| 458 |
});
|
| 459 |
|
| 460 |
function autoResize() {
|
|
|
|
| 475 |
try {
|
| 476 |
var resp = await fetch('/api/get_config');
|
| 477 |
var config = await resp.json();
|
| 478 |
+
state.config = config;
|
| 479 |
var sel = document.getElementById('model-select');
|
| 480 |
sel.innerHTML = '';
|
|
|
|
| 481 |
(config.models || []).forEach(function(m) {
|
| 482 |
var opt = document.createElement('option');
|
| 483 |
opt.value = m.key;
|
|
|
|
| 485 |
if (m.key === config.default_model) opt.selected = true;
|
| 486 |
sel.appendChild(opt);
|
| 487 |
});
|
|
|
|
| 488 |
sel.addEventListener('change', function() { switchModel(sel.value); });
|
| 489 |
+
|
| 490 |
+
// Populate language selector
|
| 491 |
+
var langSel = document.getElementById('opt-language');
|
| 492 |
+
langSel.innerHTML = '<option value="">Auto</option>';
|
| 493 |
+
(config.languages || []).forEach(function(pair) {
|
| 494 |
+
var lang = Array.isArray(pair) ? pair[0] : (pair.language || pair.name || pair);
|
| 495 |
+
var opt = document.createElement('option');
|
| 496 |
+
opt.value = lang;
|
| 497 |
+
opt.textContent = lang;
|
| 498 |
+
langSel.appendChild(opt);
|
| 499 |
+
});
|
| 500 |
+
updateFrameworkOptions();
|
| 501 |
+
|
| 502 |
+
// Populate skills selector
|
| 503 |
+
var skillSel = document.getElementById('opt-skill');
|
| 504 |
+
skillSel.innerHTML = '<option value="">None</option>';
|
| 505 |
+
(config.skills || []).forEach(function(s) {
|
| 506 |
+
var name = typeof s === 'string' ? s : (s.name || s.id || '');
|
| 507 |
+
if (!name) return;
|
| 508 |
+
var opt = document.createElement('option');
|
| 509 |
+
opt.value = name;
|
| 510 |
+
opt.textContent = name;
|
| 511 |
+
skillSel.appendChild(opt);
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
// Populate agents selector
|
| 515 |
+
var agentSel = document.getElementById('opt-agent');
|
| 516 |
+
agentSel.innerHTML = '<option value="">Default</option>';
|
| 517 |
+
(config.agents || []).forEach(function(a) {
|
| 518 |
+
var name = typeof a === 'string' ? a : (a.name || a.id || '');
|
| 519 |
+
if (!name) return;
|
| 520 |
+
var opt = document.createElement('option');
|
| 521 |
+
opt.value = name;
|
| 522 |
+
opt.textContent = name + (a.active ? ' (active)' : '');
|
| 523 |
+
if (a.active) opt.selected = true;
|
| 524 |
+
agentSel.appendChild(opt);
|
| 525 |
+
});
|
| 526 |
+
|
| 527 |
renderExamples(config);
|
| 528 |
} catch(e) { console.error('Config load failed:', e); }
|
| 529 |
}
|
| 530 |
|
| 531 |
+
function updateFrameworkOptions() {
|
| 532 |
+
if (!state.config) return;
|
| 533 |
+
var langSel = document.getElementById('opt-language');
|
| 534 |
+
var fwSel = document.getElementById('opt-framework');
|
| 535 |
+
var lang = langSel.value;
|
| 536 |
+
fwSel.innerHTML = '<option value="">Auto</option>';
|
| 537 |
+
if (!lang) return;
|
| 538 |
+
var match = (state.config.languages || []).find(function(pair) {
|
| 539 |
+
var l = Array.isArray(pair) ? pair[0] : (pair.language || pair.name || pair);
|
| 540 |
+
return l === lang;
|
| 541 |
+
});
|
| 542 |
+
if (!match) return;
|
| 543 |
+
var fws = Array.isArray(match) ? match[1] : (match.frameworks || match.fws || []);
|
| 544 |
+
(fws || []).forEach(function(fw) {
|
| 545 |
+
var opt = document.createElement('option');
|
| 546 |
+
opt.value = fw;
|
| 547 |
+
opt.textContent = fw;
|
| 548 |
+
fwSel.appendChild(opt);
|
| 549 |
+
});
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
async function setActiveAgent(name) {
|
| 553 |
+
try {
|
| 554 |
+
await fetch('/api/set_active_agent', {
|
| 555 |
+
method: 'POST',
|
| 556 |
+
headers: {'Content-Type': 'application/json'},
|
| 557 |
+
body: JSON.stringify({name: name || ''}),
|
| 558 |
+
});
|
| 559 |
+
} catch(e) { console.error('Set agent failed:', e); }
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
function handleImageSelect(event) {
|
| 563 |
+
var file = event.target.files && event.target.files[0];
|
| 564 |
+
if (!file) return;
|
| 565 |
+
var reader = new FileReader();
|
| 566 |
+
reader.onload = function(ev) {
|
| 567 |
+
state.attachedImage = {dataUrl: ev.target.result, name: file.name};
|
| 568 |
+
renderImagePreview();
|
| 569 |
+
};
|
| 570 |
+
reader.readAsDataURL(file);
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function renderImagePreview() {
|
| 574 |
+
var container = document.getElementById('image-preview-container');
|
| 575 |
+
container.innerHTML = '';
|
| 576 |
+
if (!state.attachedImage) return;
|
| 577 |
+
var div = document.createElement('span');
|
| 578 |
+
div.className = 'img-preview';
|
| 579 |
+
var img = document.createElement('img');
|
| 580 |
+
img.src = state.attachedImage.dataUrl;
|
| 581 |
+
div.appendChild(img);
|
| 582 |
+
var name = document.createElement('span');
|
| 583 |
+
name.textContent = state.attachedImage.name.substring(0, 16);
|
| 584 |
+
div.appendChild(name);
|
| 585 |
+
var rm = document.createElement('span');
|
| 586 |
+
rm.className = 'remove';
|
| 587 |
+
rm.textContent = '×';
|
| 588 |
+
rm.onclick = function() {
|
| 589 |
+
state.attachedImage = null;
|
| 590 |
+
document.getElementById('opt-image-input').value = '';
|
| 591 |
+
renderImagePreview();
|
| 592 |
+
};
|
| 593 |
+
div.appendChild(rm);
|
| 594 |
+
container.appendChild(div);
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
async function pollModelStatus() {
|
| 598 |
try {
|
| 599 |
var resp = await fetch('/api/model_status');
|
|
|
|
| 649 |
} catch(e) { console.error(e); }
|
| 650 |
}
|
| 651 |
|
| 652 |
+
async function loadMcpStatus() {
|
|
|
|
|
|
|
| 653 |
try {
|
| 654 |
+
var resp = await fetch('/api/mcp_status');
|
| 655 |
var data = await resp.json();
|
| 656 |
+
var dot = document.getElementById('mcp-dot');
|
| 657 |
+
var text = document.getElementById('mcp-pill-text');
|
| 658 |
+
var content = document.getElementById('mcp-content');
|
| 659 |
+
var servers = data.servers || {};
|
| 660 |
+
var keys = Object.keys(servers);
|
| 661 |
+
if (keys.length === 0) {
|
| 662 |
+
dot.className = 'dot disconnected';
|
| 663 |
+
text.textContent = 'MCP';
|
| 664 |
+
content.innerHTML = '<div class="mcp-empty">No MCP servers configured.<br><br>Add MCP servers in your config to extend SoniCoder with external tools.</div>';
|
| 665 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
}
|
| 667 |
+
var anyConnected = keys.some(function(k) { return servers[k].status === 'connected'; });
|
| 668 |
+
dot.className = anyConnected ? 'dot connected' : 'dot disconnected';
|
| 669 |
+
text.textContent = 'MCP (' + keys.length + ')';
|
| 670 |
+
var html = '';
|
| 671 |
+
keys.forEach(function(name) {
|
| 672 |
+
var s = servers[name];
|
| 673 |
+
var isConn = s.status === 'connected';
|
| 674 |
+
html += '<div class="mcp-server">';
|
| 675 |
+
html += '<div class="mcp-server-header">';
|
| 676 |
+
html += '<span class="mcp-server-dot ' + (isConn ? 'connected' : 'disconnected') + '"></span>';
|
| 677 |
+
html += '<span class="mcp-server-name">' + escHtml(name) + '</span>';
|
| 678 |
+
html += '<span class="tool-badge ' + (isConn ? 'success' : 'error') + '">' + s.status + '</span>';
|
| 679 |
+
html += '</div>';
|
| 680 |
+
if (s.tools && s.tools.length > 0) {
|
| 681 |
+
html += '<div class="mcp-tools">';
|
| 682 |
+
s.tools.forEach(function(t) { html += '<span class="mcp-tool-tag">' + escHtml(t) + '</span>'; });
|
| 683 |
+
html += '</div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
}
|
| 685 |
+
html += '</div>';
|
| 686 |
+
});
|
| 687 |
+
content.innerHTML = html;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
} catch(e) {
|
| 689 |
+
document.getElementById('mcp-dot').className = 'dot disconnected';
|
| 690 |
+
document.getElementById('mcp-content').innerHTML = '<div class="mcp-empty">MCP status unavailable.</div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
}
|
|
|
|
| 692 |
}
|
| 693 |
|
| 694 |
function renderExamples(config) {
|
| 695 |
+
// Use the examples from config; fall back to a curated list if missing.
|
| 696 |
+
var examples = [];
|
| 697 |
+
if (config && Array.isArray(config.examples) && config.examples.length) {
|
| 698 |
+
examples = config.examples.map(function(e) {
|
| 699 |
+
return typeof e === 'string' ? e : (e.label || e.prompt || '');
|
| 700 |
+
}).filter(Boolean);
|
| 701 |
+
}
|
| 702 |
+
if (!examples.length) {
|
| 703 |
+
examples = [
|
| 704 |
+
'Build a Flask REST API with user CRUD',
|
| 705 |
+
'Create a todo app with localStorage',
|
| 706 |
+
'Write a Python web scraper',
|
| 707 |
+
'Build a Markdown to HTML CLI tool',
|
| 708 |
+
'Create FastAPI with JWT auth',
|
| 709 |
+
'Make a React calculator component',
|
| 710 |
+
];
|
| 711 |
+
}
|
| 712 |
+
// Show at most 6 examples so the row doesn't overflow on narrow screens.
|
| 713 |
+
examples = examples.slice(0, 6);
|
| 714 |
var row = document.getElementById('examples-row');
|
| 715 |
row.innerHTML = '<span class="examples-label">Try:</span>';
|
| 716 |
examples.forEach(function(ex) {
|
|
|
|
| 719 |
chip.textContent = ex;
|
| 720 |
chip.onclick = function() {
|
| 721 |
document.getElementById('chat-input').value = ex;
|
| 722 |
+
document.getElementById('chat-input').dispatchEvent(new Event('input'));
|
| 723 |
handleSend();
|
| 724 |
};
|
| 725 |
row.appendChild(chip);
|
|
|
|
| 736 |
document.querySelectorAll('.tab-pane').forEach(function(p) {
|
| 737 |
p.classList.toggle('active', p.id === 'pane-' + tab);
|
| 738 |
});
|
|
|
|
| 739 |
}
|
| 740 |
|
| 741 |
// ═══ FULLSCREEN ═══
|
|
|
|
| 771 |
var bodyClass = role === 'assistant' ? '<div class="msg-body">' : '';
|
| 772 |
var bodyClose = role === 'assistant' ? '</div>' : '';
|
| 773 |
var iterationHtml = extra.iteration ? '<div class="iteration-badge">Iteration ' + extra.iteration + '/' + extra.max_iterations + '</div>' : '';
|
| 774 |
+
div.innerHTML = iterationHtml + prefix + '<span class="msg-content">' + renderMd(content) + '</span>' + bodyClose + bodyClose;
|
| 775 |
container.appendChild(div);
|
| 776 |
container.scrollTop = container.scrollHeight;
|
| 777 |
return div;
|
|
|
|
| 780 |
function renderMd(text) {
|
| 781 |
if (!text) return '';
|
| 782 |
text = escHtml(text);
|
| 783 |
+
// Code blocks
|
| 784 |
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
|
| 785 |
var id = 'cb_' + Date.now() + Math.random().toString(36).substr(2,4);
|
| 786 |
return '<div class="code-block-wrap"><div class="code-block-header"><span class="code-lang">' + (lang||'code') + '</span><button class="btn-copy" onclick="copyBlock(\'' + id + '\')">📋 Copy</button></div><pre><code id="' + id + '">' + code.trim() + '</code></pre></div>';
|
|
|
|
| 824 |
var div = document.createElement('div');
|
| 825 |
div.className = 'tool-block open';
|
| 826 |
div.id = id;
|
|
|
|
| 827 |
var argsStr = '';
|
| 828 |
if (args) {
|
| 829 |
Object.entries(args).forEach(function(e) {
|
|
|
|
| 831 |
argsStr += escHtml(e[0] + ': ' + v) + '\n';
|
| 832 |
});
|
| 833 |
}
|
|
|
|
| 834 |
div.innerHTML =
|
| 835 |
'<div class="tool-header">' +
|
| 836 |
'<span class="tool-icon">⚡</span>' +
|
|
|
|
| 839 |
'</div>' +
|
| 840 |
'<div class="tool-args">' + argsStr + '</div>' +
|
| 841 |
'<div class="tool-result"></div>';
|
|
|
|
| 842 |
container.appendChild(div);
|
| 843 |
container.scrollTop = container.scrollHeight;
|
| 844 |
state.toolCalls[id] = div;
|
|
|
|
| 850 |
if (!div) return;
|
| 851 |
var badge = div.querySelector('.tool-badge');
|
| 852 |
var result = div.querySelector('.tool-result');
|
|
|
|
| 853 |
if (success) {
|
| 854 |
badge.className = 'tool-badge success';
|
| 855 |
badge.textContent = 'DONE';
|
|
|
|
| 857 |
badge.className = 'tool-badge error';
|
| 858 |
badge.textContent = 'ERROR';
|
| 859 |
}
|
|
|
|
| 860 |
if (output) {
|
| 861 |
result.textContent = output;
|
| 862 |
result.className = success ? 'tool-result' : 'tool-result error-output';
|
|
|
|
| 903 |
startStreaming();
|
| 904 |
setStatus('status-working', 'GENERATING...');
|
| 905 |
|
| 906 |
+
// Collect option values
|
| 907 |
+
var targetLanguage = (document.getElementById('opt-language') || {}).value || '';
|
| 908 |
+
var targetFramework = (document.getElementById('opt-framework') || {}).value || '';
|
| 909 |
+
var skill = (document.getElementById('opt-skill') || {}).value || '';
|
| 910 |
+
var agent = (document.getElementById('opt-agent') || {}).value || '';
|
| 911 |
+
var searchEnabled = (document.getElementById('opt-search') || {}).checked || false;
|
| 912 |
+
|
| 913 |
+
// Prepend /skill command if a skill is selected and not already in the message
|
| 914 |
+
var finalMessage = message;
|
| 915 |
+
if (skill && !message.startsWith('/skill')) {
|
| 916 |
+
finalMessage = '/skill ' + skill + '\n\n' + message;
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
// Attach image if present
|
| 920 |
+
var imageUrl = '';
|
| 921 |
+
if (state.attachedImage && state.attachedImage.dataUrl) {
|
| 922 |
+
imageUrl = state.attachedImage.dataUrl;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
try {
|
| 926 |
var resp = await fetch('/chat', {
|
| 927 |
method: 'POST',
|
| 928 |
headers: {'Content-Type': 'application/json'},
|
| 929 |
+
body: JSON.stringify({
|
| 930 |
+
message: finalMessage,
|
| 931 |
+
session_id: state.sessionId,
|
| 932 |
+
target_language: targetLanguage,
|
| 933 |
+
target_framework: targetFramework,
|
| 934 |
+
search_enabled: searchEnabled ? 'true' : 'false',
|
| 935 |
+
agent_name: agent,
|
| 936 |
+
image_url: imageUrl,
|
| 937 |
+
}),
|
| 938 |
});
|
| 939 |
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
|
|
| 940 |
var reader = resp.body.getReader();
|
| 941 |
var decoder = new TextDecoder();
|
| 942 |
var buffer = '';
|
|
|
|
| 943 |
while (true) {
|
| 944 |
var result = await reader.read();
|
| 945 |
if (result.done) break;
|
| 946 |
buffer += decoder.decode(result.value, {stream: true});
|
| 947 |
var lines = buffer.split('\n');
|
| 948 |
buffer = lines.pop() || '';
|
|
|
|
| 949 |
for (var i = 0; i < lines.length; i++) {
|
| 950 |
var line = lines[i];
|
| 951 |
if (!line.trim()) continue;
|
|
|
|
| 968 |
break;
|
| 969 |
}
|
| 970 |
}
|
| 971 |
+
// Capture code for Code tab
|
| 972 |
if (ev.result.output && (ev.result.output.includes('<!DOCTYPE') || ev.result.output.includes('<html'))) {
|
| 973 |
showPreview(ev.result.output);
|
| 974 |
}
|
|
|
|
| 986 |
if (ev.iterations) setTimeout(refreshWorkspace, 500);
|
| 987 |
setStatus('status-success', 'COMPLETE');
|
| 988 |
setTimeout(function() { if (!state.isGenerating) setStatus('status-idle', 'IDLE'); }, 3000);
|
| 989 |
+
// Clear attached image after a successful send
|
| 990 |
+
if (state.attachedImage) {
|
| 991 |
+
state.attachedImage = null;
|
| 992 |
+
document.getElementById('opt-image-input').value = '';
|
| 993 |
+
renderImagePreview();
|
| 994 |
+
}
|
| 995 |
break;
|
| 996 |
case 'error':
|
| 997 |
finalizeStream('Error: ' + ev.error);
|
|
|
|
| 1007 |
setStatus('status-error', 'CONNECTION ERROR');
|
| 1008 |
}
|
| 1009 |
}
|
|
|
|
| 1010 |
state.isGenerating = false;
|
| 1011 |
document.getElementById('btn-send').style.display = 'flex';
|
| 1012 |
document.getElementById('btn-stop').style.display = 'none';
|
requirements.txt
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
# SoniCoder v2 — Optimized AI Code Agent
|
| 2 |
# Architecture inspired by Gemini CLI and Claude Code
|
| 3 |
|
| 4 |
-
fastapi>=0.115.0
|
| 5 |
-
uvicorn[standard]>=0.30.0
|
| 6 |
gradio==6.19.0
|
| 7 |
transformers>=4.45.0
|
| 8 |
torch>=2.1.0
|
|
@@ -12,8 +10,8 @@ requests>=2.31.0
|
|
| 12 |
beautifulsoup4>=4.12.0
|
| 13 |
Pillow>=10.0
|
| 14 |
|
| 15 |
-
# MCP support
|
| 16 |
-
|
| 17 |
|
| 18 |
# Optional: VLM support
|
| 19 |
# torchvision>=0.16.0
|
|
|
|
| 1 |
# SoniCoder v2 — Optimized AI Code Agent
|
| 2 |
# Architecture inspired by Gemini CLI and Claude Code
|
| 3 |
|
|
|
|
|
|
|
| 4 |
gradio==6.19.0
|
| 5 |
transformers>=4.45.0
|
| 6 |
torch>=2.1.0
|
|
|
|
| 10 |
beautifulsoup4>=4.12.0
|
| 11 |
Pillow>=10.0
|
| 12 |
|
| 13 |
+
# MCP support
|
| 14 |
+
mcp>=1.0.0
|
| 15 |
|
| 16 |
# Optional: VLM support
|
| 17 |
# torchvision>=0.16.0
|
sonicoder/__init__.py
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,474 +0,0 @@
|
|
| 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 ..model.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
DELETED
|
@@ -1,106 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,455 +0,0 @@
|
|
| 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 |
-
# ── MCP Server Catalog ─────────────────────────────────────────────────
|
| 167 |
-
# Pre-configured MCP servers users can enable from the UI.
|
| 168 |
-
|
| 169 |
-
MCP_CATALOG: list[dict[str, Any]] = [
|
| 170 |
-
{
|
| 171 |
-
"key": "filesystem",
|
| 172 |
-
"name": "Filesystem",
|
| 173 |
-
"description": "Read, write, and manage files on the server. Full file system access with safe path resolution.",
|
| 174 |
-
"category": "core",
|
| 175 |
-
"icon": "📁",
|
| 176 |
-
"transport": "stdio",
|
| 177 |
-
"command": "npx",
|
| 178 |
-
"args": ["-y", "@anthropic/mcp-filesystem", "."],
|
| 179 |
-
"install_hint": "npx -y @anthropic/mcp-filesystem",
|
| 180 |
-
"repo_url": "https://github.com/anthropics/mcp-filesystem",
|
| 181 |
-
},
|
| 182 |
-
{
|
| 183 |
-
"key": "fetch",
|
| 184 |
-
"name": "Fetch / HTTP",
|
| 185 |
-
"description": "Make HTTP requests to any URL. Fetch web pages, APIs, and resources for the agent to read.",
|
| 186 |
-
"category": "core",
|
| 187 |
-
"icon": "🌐",
|
| 188 |
-
"transport": "stdio",
|
| 189 |
-
"command": "npx",
|
| 190 |
-
"args": ["-y", "@anthropic/mcp-fetch"],
|
| 191 |
-
"install_hint": "npx -y @anthropic/mcp-fetch",
|
| 192 |
-
"repo_url": "https://github.com/anthropics/mcp-fetch",
|
| 193 |
-
},
|
| 194 |
-
{
|
| 195 |
-
"key": "memory",
|
| 196 |
-
"name": "Memory / Knowledge Graph",
|
| 197 |
-
"description": "Persistent knowledge graph memory. Store and retrieve facts, entities, and relationships across sessions.",
|
| 198 |
-
"category": "core",
|
| 199 |
-
"icon": "🧠",
|
| 200 |
-
"transport": "stdio",
|
| 201 |
-
"command": "npx",
|
| 202 |
-
"args": ["-y", "@anthropic/mcp-memory"],
|
| 203 |
-
"install_hint": "npx -y @anthropic/mcp-memory",
|
| 204 |
-
"repo_url": "https://github.com/anthropics/mcp-memory",
|
| 205 |
-
},
|
| 206 |
-
{
|
| 207 |
-
"key": "sequential-thinking",
|
| 208 |
-
"name": "Sequential Thinking",
|
| 209 |
-
"description": "Dynamic thinking and reflection. Break down complex problems into ordered reasoning steps.",
|
| 210 |
-
"category": "reasoning",
|
| 211 |
-
"icon": "💭",
|
| 212 |
-
"transport": "stdio",
|
| 213 |
-
"command": "npx",
|
| 214 |
-
"args": ["-y", "@anthropic/mcp-sequential-thinking"],
|
| 215 |
-
"install_hint": "npx -y @anthropic/mcp-sequential-thinking",
|
| 216 |
-
"repo_url": "https://github.com/anthropics/mcp-sequential-thinking",
|
| 217 |
-
},
|
| 218 |
-
{
|
| 219 |
-
"key": "github",
|
| 220 |
-
"name": "GitHub",
|
| 221 |
-
"description": "Search repos, manage issues/PRs, create branches, and interact with the GitHub API directly.",
|
| 222 |
-
"category": "dev-tools",
|
| 223 |
-
"icon": "🐙",
|
| 224 |
-
"transport": "stdio",
|
| 225 |
-
"command": "npx",
|
| 226 |
-
"args": ["-y", "@anthropic/mcp-github"],
|
| 227 |
-
"env": {"GITHUB_TOKEN": ""},
|
| 228 |
-
"install_hint": "Set GITHUB_TOKEN env var first, then: npx -y @anthropic/mcp-github",
|
| 229 |
-
"repo_url": "https://github.com/anthropics/mcp-github",
|
| 230 |
-
},
|
| 231 |
-
{
|
| 232 |
-
"key": "git",
|
| 233 |
-
"name": "Git",
|
| 234 |
-
"description": "Git operations: diff, log, status, branch management, and commit history analysis.",
|
| 235 |
-
"category": "dev-tools",
|
| 236 |
-
"icon": "🔀",
|
| 237 |
-
"transport": "stdio",
|
| 238 |
-
"command": "npx",
|
| 239 |
-
"args": ["-y", "@anthropic/mcp-git"],
|
| 240 |
-
"install_hint": "npx -y @anthropic/mcp-git",
|
| 241 |
-
"repo_url": "https://github.com/anthropics/mcp-git",
|
| 242 |
-
},
|
| 243 |
-
{
|
| 244 |
-
"key": "docker",
|
| 245 |
-
"name": "Docker",
|
| 246 |
-
"description": "Manage Docker containers, images, and compose stacks. Build, run, and inspect containers.",
|
| 247 |
-
"category": "dev-tools",
|
| 248 |
-
"icon": "🐳",
|
| 249 |
-
"transport": "stdio",
|
| 250 |
-
"command": "npx",
|
| 251 |
-
"args": ["-y", "@anthropic/mcp-docker"],
|
| 252 |
-
"install_hint": "npx -y @anthropic/mcp-docker (requires Docker daemon)",
|
| 253 |
-
"repo_url": "https://github.com/anthropics/mcp-docker",
|
| 254 |
-
},
|
| 255 |
-
{
|
| 256 |
-
"key": "postgres",
|
| 257 |
-
"name": "PostgreSQL",
|
| 258 |
-
"description": "Read/write PostgreSQL databases. Run queries, inspect schemas, and manage tables.",
|
| 259 |
-
"category": "database",
|
| 260 |
-
"icon": "🐘",
|
| 261 |
-
"transport": "stdio",
|
| 262 |
-
"command": "npx",
|
| 263 |
-
"args": ["-y", "@anthropic/mcp-postgres"],
|
| 264 |
-
"env": {"DATABASE_URL": "postgresql://localhost:5432/mydb"},
|
| 265 |
-
"install_hint": "Set DATABASE_URL, then: npx -y @anthropic/mcp-postgres",
|
| 266 |
-
"repo_url": "https://github.com/anthropics/mcp-postgres",
|
| 267 |
-
},
|
| 268 |
-
{
|
| 269 |
-
"key": "sqlite",
|
| 270 |
-
"name": "SQLite",
|
| 271 |
-
"description": "Read/write SQLite databases. Query tables, inspect schemas, run migrations.",
|
| 272 |
-
"category": "database",
|
| 273 |
-
"icon": "🗃️",
|
| 274 |
-
"transport": "stdio",
|
| 275 |
-
"command": "npx",
|
| 276 |
-
"args": ["-y", "@anthropic/mcp-sqlite", "--db-path", "./workspace/data.db"],
|
| 277 |
-
"install_hint": "npx -y @anthropic/mcp-sqlite --db-path ./data.db",
|
| 278 |
-
"repo_url": "https://github.com/anthropics/mcp-sqlite",
|
| 279 |
-
},
|
| 280 |
-
{
|
| 281 |
-
"key": "redis",
|
| 282 |
-
"name": "Redis",
|
| 283 |
-
"description": "Redis cache and data structure operations. Get/set keys, manage lists, sets, and hashes.",
|
| 284 |
-
"category": "database",
|
| 285 |
-
"icon": "🔴",
|
| 286 |
-
"transport": "stdio",
|
| 287 |
-
"command": "npx",
|
| 288 |
-
"args": ["-y", "@anthropic/mcp-redis"],
|
| 289 |
-
"env": {"REDIS_URL": "redis://localhost:6379"},
|
| 290 |
-
"install_hint": "Set REDIS_URL, then: npx -y @anthropic/mcp-redis",
|
| 291 |
-
"repo_url": "https://github.com/anthropics/mcp-redis",
|
| 292 |
-
},
|
| 293 |
-
{
|
| 294 |
-
"key": "brave-search",
|
| 295 |
-
"name": "Brave Search",
|
| 296 |
-
"description": "Web search powered by Brave. Search the web, get AI summaries, and fetch page contents.",
|
| 297 |
-
"category": "search",
|
| 298 |
-
"icon": "🔍",
|
| 299 |
-
"transport": "stdio",
|
| 300 |
-
"command": "npx",
|
| 301 |
-
"args": ["-y", "@anthropic/mcp-brave-search"],
|
| 302 |
-
"env": {"BRAVE_API_KEY": ""},
|
| 303 |
-
"install_hint": "Set BRAVE_API_KEY, then: npx -y @anthropic/mcp-brave-search",
|
| 304 |
-
"repo_url": "https://github.com/anthropics/mcp-brave-search",
|
| 305 |
-
},
|
| 306 |
-
{
|
| 307 |
-
"key": "puppeteer",
|
| 308 |
-
"name": "Puppeteer / Browser",
|
| 309 |
-
"description": "Headless browser automation. Navigate pages, take screenshots, extract content, and fill forms.",
|
| 310 |
-
"category": "browser",
|
| 311 |
-
"icon": "🎭",
|
| 312 |
-
"transport": "stdio",
|
| 313 |
-
"command": "npx",
|
| 314 |
-
"args": ["-y", "@anthropic/mcp-puppeteer"],
|
| 315 |
-
"install_hint": "npx -y @anthropic/mcp-puppeteer (requires Chromium)",
|
| 316 |
-
"repo_url": "https://github.com/anthropics/mcp-puppeteer",
|
| 317 |
-
},
|
| 318 |
-
{
|
| 319 |
-
"key": "slack",
|
| 320 |
-
"name": "Slack",
|
| 321 |
-
"description": "Send messages, read channels, and manage Slack workspaces via the Slack API.",
|
| 322 |
-
"category": "integrations",
|
| 323 |
-
"icon": "💬",
|
| 324 |
-
"transport": "stdio",
|
| 325 |
-
"command": "npx",
|
| 326 |
-
"args": ["-y", "@anthropic/mcp-slack"],
|
| 327 |
-
"env": {"SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": ""},
|
| 328 |
-
"install_hint": "Set SLACK_BOT_TOKEN + SLACK_TEAM_ID, then: npx -y @anthropic/mcp-slack",
|
| 329 |
-
"repo_url": "https://github.com/anthropics/mcp-slack",
|
| 330 |
-
},
|
| 331 |
-
{
|
| 332 |
-
"key": "context7",
|
| 333 |
-
"name": "Context7 / Docs",
|
| 334 |
-
"description": "Look up library documentation. Get up-to-date docs for npm packages, Python libs, and more.",
|
| 335 |
-
"category": "search",
|
| 336 |
-
"icon": "📚",
|
| 337 |
-
"transport": "stdio",
|
| 338 |
-
"command": "npx",
|
| 339 |
-
"args": ["-y", "@anthropic/mcp-context7"],
|
| 340 |
-
"install_hint": "npx -y @anthropic/mcp-context7",
|
| 341 |
-
"repo_url": "https://github.com/anthropics/mcp-context7",
|
| 342 |
-
},
|
| 343 |
-
{
|
| 344 |
-
"key": "everything",
|
| 345 |
-
"name": "Everything (Meta)",
|
| 346 |
-
"description": "Combines multiple MCP tools into one server: filesystem, fetch, memory, and more.",
|
| 347 |
-
"category": "core",
|
| 348 |
-
"icon": "⚡",
|
| 349 |
-
"transport": "stdio",
|
| 350 |
-
"command": "npx",
|
| 351 |
-
"args": ["-y", "@anthropic/mcp-everything"],
|
| 352 |
-
"install_hint": "npx -y @anthropic/mcp-everything",
|
| 353 |
-
"repo_url": "https://github.com/anthropics/mcp-everything",
|
| 354 |
-
},
|
| 355 |
-
{
|
| 356 |
-
"key": "sentry",
|
| 357 |
-
"name": "Sentry",
|
| 358 |
-
"description": "Query Sentry for error tracking. List issues, get stack traces, and manage projects.",
|
| 359 |
-
"category": "integrations",
|
| 360 |
-
"icon": "🛡️",
|
| 361 |
-
"transport": "stdio",
|
| 362 |
-
"command": "npx",
|
| 363 |
-
"args": ["-y", "@anthropic/mcp-sentry"],
|
| 364 |
-
"env": {"SENTRY_TOKEN": "", "SENTRY_ORG": "", "SENTRY_PROJECT": ""},
|
| 365 |
-
"install_hint": "Set SENTRY_TOKEN/ORG/PROJECT, then: npx -y @anthropic/mcp-sentry",
|
| 366 |
-
"repo_url": "https://github.com/anthropics/mcp-sentry",
|
| 367 |
-
},
|
| 368 |
-
{
|
| 369 |
-
"key": "google-maps",
|
| 370 |
-
"name": "Google Maps",
|
| 371 |
-
"description": "Search places, get directions, geocode addresses, and embed maps via Google Maps API.",
|
| 372 |
-
"category": "integrations",
|
| 373 |
-
"icon": "🗺️",
|
| 374 |
-
"transport": "stdio",
|
| 375 |
-
"command": "npx",
|
| 376 |
-
"args": ["-y", "@anthropic/mcp-google-maps"],
|
| 377 |
-
"env": {"GOOGLE_MAPS_API_KEY": ""},
|
| 378 |
-
"install_hint": "Set GOOGLE_MAPS_API_KEY, then: npx -y @anthropic/mcp-google-maps",
|
| 379 |
-
"repo_url": "https://github.com/anthropics/mcp-google-maps",
|
| 380 |
-
},
|
| 381 |
-
{
|
| 382 |
-
"key": "time",
|
| 383 |
-
"name": "Time / Timezone",
|
| 384 |
-
"description": "Get current time in any timezone. Convert between timezones and calculate time differences.",
|
| 385 |
-
"category": "utilities",
|
| 386 |
-
"icon": "⏰",
|
| 387 |
-
"transport": "stdio",
|
| 388 |
-
"command": "npx",
|
| 389 |
-
"args": ["-y", "@anthropic/mcp-time"],
|
| 390 |
-
"install_hint": "npx -y @anthropic/mcp-time",
|
| 391 |
-
"repo_url": "https://github.com/anthropics/mcp-time",
|
| 392 |
-
},
|
| 393 |
-
]
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def get_available_models() -> dict[str, ModelConfig]:
|
| 397 |
-
"""Return all registered models."""
|
| 398 |
-
return MODEL_REGISTRY.copy()
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
def get_model_config(model_key: str) -> ModelConfig | None:
|
| 402 |
-
"""Get a model config by key."""
|
| 403 |
-
return MODEL_REGISTRY.get(model_key)
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
def load_settings(project_dir: Path | None = None) -> Settings:
|
| 407 |
-
"""Load settings with hierarchical cascading.
|
| 408 |
-
|
| 409 |
-
Priority (highest → lowest):
|
| 410 |
-
1. Environment variables
|
| 411 |
-
2. .sonicoder/settings.local.json (local overrides, gitignored)
|
| 412 |
-
3. .sonicoder/settings.json (project settings)
|
| 413 |
-
4. Defaults
|
| 414 |
-
"""
|
| 415 |
-
settings = Settings()
|
| 416 |
-
|
| 417 |
-
search_dirs = [Path.cwd()]
|
| 418 |
-
if project_dir:
|
| 419 |
-
search_dirs.insert(0, project_dir)
|
| 420 |
-
|
| 421 |
-
# Load project-level settings
|
| 422 |
-
for d in search_dirs:
|
| 423 |
-
local_path = d / CONFIG_DIR / "settings.local.json"
|
| 424 |
-
proj_path = d / CONFIG_DIR / "settings.json"
|
| 425 |
-
|
| 426 |
-
for path in [local_path, proj_path]:
|
| 427 |
-
if path.exists():
|
| 428 |
-
try:
|
| 429 |
-
data = json.loads(path.read_text(encoding="utf-8"))
|
| 430 |
-
# Parse MCP server configs
|
| 431 |
-
if "mcp_servers" in data:
|
| 432 |
-
data["mcp_servers"] = [
|
| 433 |
-
MCPServerConfig(**s) for s in data["mcp_servers"]
|
| 434 |
-
]
|
| 435 |
-
if "policy_rules" in data:
|
| 436 |
-
data["policy_rules"] = [
|
| 437 |
-
PolicyRule(**r) for r in data["policy_rules"]
|
| 438 |
-
]
|
| 439 |
-
settings = settings.merge(Settings.from_dict(data))
|
| 440 |
-
except (json.JSONDecodeError, TypeError) as e:
|
| 441 |
-
print(f"[config] Warning: Failed to parse {path}: {e}")
|
| 442 |
-
|
| 443 |
-
# Environment variable overrides
|
| 444 |
-
env_model = os.environ.get("SONICODER_MODEL")
|
| 445 |
-
if env_model and env_model in MODEL_REGISTRY:
|
| 446 |
-
settings.default_model = env_model
|
| 447 |
-
|
| 448 |
-
env_temp = os.environ.get("SONICODER_TEMPERATURE")
|
| 449 |
-
if env_temp:
|
| 450 |
-
try:
|
| 451 |
-
settings.temperature = float(env_temp)
|
| 452 |
-
except ValueError:
|
| 453 |
-
pass
|
| 454 |
-
|
| 455 |
-
return settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sonicoder/context/__init__.py
DELETED
|
@@ -1,154 +0,0 @@
|
|
| 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
DELETED
|
File without changes
|
sonicoder/mcp/client_manager.py
DELETED
|
File without changes
|
sonicoder/model/__init__.py
DELETED
|
File without changes
|
sonicoder/model/inference.py
DELETED
|
@@ -1,224 +0,0 @@
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sonicoder/model/loader.py
DELETED
|
@@ -1,181 +0,0 @@
|
|
| 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 |
-
"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
DELETED
|
File without changes
|
sonicoder/server/__init__.py
DELETED
|
@@ -1,413 +0,0 @@
|
|
| 1 |
-
"""Server routes — pure FastAPI with custom HTML frontend.
|
| 2 |
-
|
| 3 |
-
Uses FastAPI directly (not gradio.Server) to guarantee our custom
|
| 4 |
-
index.html is served at / without Gradio's default UI taking over.
|
| 5 |
-
|
| 6 |
-
Frontend uses plain fetch() — no @gradio/client needed.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import json
|
| 12 |
-
import logging
|
| 13 |
-
import uuid
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
from typing import Any, Generator
|
| 16 |
-
|
| 17 |
-
from fastapi import FastAPI, Request
|
| 18 |
-
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
| 19 |
-
|
| 20 |
-
from ..config import (
|
| 21 |
-
Settings, get_available_models, load_settings,
|
| 22 |
-
WORKSPACE_ROOT, CONFIG_DIR, MCP_CATALOG,
|
| 23 |
-
)
|
| 24 |
-
from ..model.loader import (
|
| 25 |
-
get_model_status, start_background_load, switch_model as switch_model_fn,
|
| 26 |
-
)
|
| 27 |
-
from ..tools import get_tool_registry, ToolRegistry
|
| 28 |
-
from ..tools.fs import BUILTIN_TOOLS as FS_TOOLS
|
| 29 |
-
from ..tools.bash import BUILTIN_TOOLS as BASH_TOOLS
|
| 30 |
-
from ..tools.web import BUILTIN_TOOLS as WEB_TOOLS
|
| 31 |
-
from ..tools.todos import BUILTIN_TOOLS as TODO_TOOLS
|
| 32 |
-
from ..agent import AgentSession, register_builtin_tools
|
| 33 |
-
from ..mcp import get_mcp_manager
|
| 34 |
-
from ..policy import get_policy_engine
|
| 35 |
-
|
| 36 |
-
logger = logging.getLogger(__name__)
|
| 37 |
-
|
| 38 |
-
# ── Global State ───────────────────────────────────────────────────────
|
| 39 |
-
|
| 40 |
-
_settings: Settings | None = None
|
| 41 |
-
_registry: ToolRegistry | None = None
|
| 42 |
-
_sessions: dict[str, AgentSession] = {}
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _get_settings() -> Settings:
|
| 46 |
-
global _settings
|
| 47 |
-
if _settings is None:
|
| 48 |
-
_settings = load_settings()
|
| 49 |
-
return _settings
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def _get_registry() -> ToolRegistry:
|
| 53 |
-
global _registry
|
| 54 |
-
if _registry is None:
|
| 55 |
-
_registry = get_tool_registry()
|
| 56 |
-
register_builtin_tools(_registry)
|
| 57 |
-
settings = _get_settings()
|
| 58 |
-
_registry.set_filters(settings.enabled_tools, settings.disabled_tools)
|
| 59 |
-
return _registry
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _get_or_create_session(session_id: str = "") -> tuple[str, AgentSession]:
|
| 63 |
-
if not session_id:
|
| 64 |
-
session_id = str(uuid.uuid4())[:12]
|
| 65 |
-
if session_id not in _sessions:
|
| 66 |
-
_sessions[session_id] = AgentSession(
|
| 67 |
-
settings=_get_settings(),
|
| 68 |
-
tool_registry=_get_registry(),
|
| 69 |
-
)
|
| 70 |
-
return session_id, _sessions[session_id]
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
# ── JSON helpers ───────────────────────────────────────────────────────
|
| 74 |
-
|
| 75 |
-
def _json(data) -> JSONResponse:
|
| 76 |
-
return JSONResponse(content=data if isinstance(data, dict | list) else json.loads(data))
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _json_str(data) -> JSONResponse:
|
| 80 |
-
return JSONResponse(content=json.loads(data) if isinstance(data, str) else data)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
# ── Create App ────────────────────────────────────────────────────────
|
| 84 |
-
|
| 85 |
-
def create_app():
|
| 86 |
-
"""Create and return a FastAPI app with custom HTML frontend.
|
| 87 |
-
|
| 88 |
-
Uses FastAPI directly (not gradio.Server) so our custom index.html
|
| 89 |
-
is always served at / without being overridden by Gradio's routes.
|
| 90 |
-
"""
|
| 91 |
-
app = FastAPI(title="SoniCoder v2", docs_url=None, redoc_url=None)
|
| 92 |
-
|
| 93 |
-
# ── Serve index.html from project root ─────────────────────────────
|
| 94 |
-
|
| 95 |
-
@app.get("/", response_class=HTMLResponse)
|
| 96 |
-
async def homepage():
|
| 97 |
-
html_path = Path(__file__).parent.parent.parent / "index.html"
|
| 98 |
-
if not html_path.exists():
|
| 99 |
-
return HTMLResponse(
|
| 100 |
-
"<h1>SoniCoder v2</h1><p>index.html not found at project root</p>",
|
| 101 |
-
status_code=500,
|
| 102 |
-
)
|
| 103 |
-
with open(html_path, "r", encoding="utf-8") as f:
|
| 104 |
-
return f.read()
|
| 105 |
-
|
| 106 |
-
# ── Config ─────────────────────────────────────────────────────────
|
| 107 |
-
|
| 108 |
-
@app.get("/api/get_config")
|
| 109 |
-
async def get_config():
|
| 110 |
-
models = get_available_models()
|
| 111 |
-
model_list = []
|
| 112 |
-
for key, config in models.items():
|
| 113 |
-
model_list.append({
|
| 114 |
-
"key": key,
|
| 115 |
-
"name": config.name,
|
| 116 |
-
"model_type": config.model_type,
|
| 117 |
-
"size_gb": config.size_gb,
|
| 118 |
-
"description": config.description,
|
| 119 |
-
})
|
| 120 |
-
return JSONResponse(content={
|
| 121 |
-
"models": model_list,
|
| 122 |
-
"default_model": _get_settings().default_model,
|
| 123 |
-
"workspace": str(WORKSPACE_ROOT),
|
| 124 |
-
"version": "2.0.0",
|
| 125 |
-
})
|
| 126 |
-
|
| 127 |
-
# ── Model status ───────────────────────────────────────────────────
|
| 128 |
-
|
| 129 |
-
@app.get("/api/model_status")
|
| 130 |
-
async def model_status():
|
| 131 |
-
return _json(get_model_status())
|
| 132 |
-
|
| 133 |
-
# ── Switch model ────────────��──────────────────────────────────────
|
| 134 |
-
|
| 135 |
-
@app.post("/api/switch_model")
|
| 136 |
-
async def switch_model(request: Request):
|
| 137 |
-
body = await request.json()
|
| 138 |
-
model_key = body.get("model_key", "")
|
| 139 |
-
switch_model_fn(model_key)
|
| 140 |
-
return JSONResponse(content={"status": "switching", "model": model_key})
|
| 141 |
-
|
| 142 |
-
# ── List models ────────────────────────────────────────────────────
|
| 143 |
-
|
| 144 |
-
@app.get("/api/list_models")
|
| 145 |
-
async def list_models():
|
| 146 |
-
models = get_available_models()
|
| 147 |
-
current = get_model_status().get("model_key", "")
|
| 148 |
-
result = []
|
| 149 |
-
for key, config in models.items():
|
| 150 |
-
result.append({
|
| 151 |
-
"key": key,
|
| 152 |
-
"name": config.name,
|
| 153 |
-
"model_type": config.model_type,
|
| 154 |
-
"size_gb": config.size_gb,
|
| 155 |
-
"description": config.description,
|
| 156 |
-
"current": key == current,
|
| 157 |
-
})
|
| 158 |
-
return JSONResponse(content=result)
|
| 159 |
-
|
| 160 |
-
# ── Workspace tree ─────────────────────────────────────────────────
|
| 161 |
-
|
| 162 |
-
@app.get("/api/workspace_tree")
|
| 163 |
-
async def workspace_tree(max_depth: int = 3):
|
| 164 |
-
tree = []
|
| 165 |
-
|
| 166 |
-
def walk(path: Path, prefix: str = "", depth: int = 0):
|
| 167 |
-
if depth >= max_depth:
|
| 168 |
-
return
|
| 169 |
-
try:
|
| 170 |
-
items = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
| 171 |
-
except PermissionError:
|
| 172 |
-
return
|
| 173 |
-
skip = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".sonicoder"}
|
| 174 |
-
for i, item in enumerate(items):
|
| 175 |
-
if item.name.startswith(".") or item.name in skip:
|
| 176 |
-
continue
|
| 177 |
-
is_last = i == len(items) - 1
|
| 178 |
-
connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
|
| 179 |
-
if item.is_dir():
|
| 180 |
-
tree.append(f"{prefix}{connector}{item.name}/")
|
| 181 |
-
extension = " " if is_last else "\u2502 "
|
| 182 |
-
walk(item, prefix + extension, depth + 1)
|
| 183 |
-
else:
|
| 184 |
-
tree.append(f"{prefix}{connector}{item.name}")
|
| 185 |
-
|
| 186 |
-
if WORKSPACE_ROOT.exists():
|
| 187 |
-
walk(WORKSPACE_ROOT)
|
| 188 |
-
|
| 189 |
-
return JSONResponse(content={
|
| 190 |
-
"tree": "\n".join(tree) if tree else "(empty workspace)",
|
| 191 |
-
"root": str(WORKSPACE_ROOT),
|
| 192 |
-
})
|
| 193 |
-
|
| 194 |
-
# ── Workspace read ─────────────────────────────────────────────────
|
| 195 |
-
|
| 196 |
-
@app.post("/api/workspace_read")
|
| 197 |
-
async def workspace_read(request: Request):
|
| 198 |
-
body = await request.json()
|
| 199 |
-
path = body.get("path", "")
|
| 200 |
-
offset = body.get("offset", 1)
|
| 201 |
-
limit = body.get("limit", 500)
|
| 202 |
-
from ..tools.fs import _resolve_safe
|
| 203 |
-
try:
|
| 204 |
-
resolved = _resolve_safe(path)
|
| 205 |
-
if not resolved.exists():
|
| 206 |
-
return JSONResponse(content={"error": f"File not found: {path}"})
|
| 207 |
-
content = resolved.read_text(encoding="utf-8", errors="replace")
|
| 208 |
-
lines = content.splitlines()
|
| 209 |
-
total = len(lines)
|
| 210 |
-
start = max(0, offset - 1)
|
| 211 |
-
end = min(total, start + limit)
|
| 212 |
-
selected = lines[start:end]
|
| 213 |
-
numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
|
| 214 |
-
return JSONResponse(content={
|
| 215 |
-
"content": "\n".join(numbered),
|
| 216 |
-
"total_lines": total,
|
| 217 |
-
"truncated": end < total,
|
| 218 |
-
})
|
| 219 |
-
except Exception as e:
|
| 220 |
-
return JSONResponse(content={"error": str(e)})
|
| 221 |
-
|
| 222 |
-
# ── Workspace write ────────────────────────────────────────────────
|
| 223 |
-
|
| 224 |
-
@app.post("/api/workspace_write")
|
| 225 |
-
async def workspace_write(request: Request):
|
| 226 |
-
body = await request.json()
|
| 227 |
-
path = body.get("path", "")
|
| 228 |
-
content = body.get("content", "")
|
| 229 |
-
from ..tools.fs import _resolve_safe
|
| 230 |
-
try:
|
| 231 |
-
resolved = _resolve_safe(path)
|
| 232 |
-
resolved.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
-
resolved.write_text(content, encoding="utf-8")
|
| 234 |
-
return JSONResponse(content={"success": True, "path": path})
|
| 235 |
-
except Exception as e:
|
| 236 |
-
return JSONResponse(content={"error": str(e)})
|
| 237 |
-
|
| 238 |
-
# ── Workspace bash ─────────────────────────────────────────────────
|
| 239 |
-
|
| 240 |
-
@app.post("/api/workspace_bash")
|
| 241 |
-
async def workspace_bash(request: Request):
|
| 242 |
-
body = await request.json()
|
| 243 |
-
command = body.get("command", "")
|
| 244 |
-
from ..tools.bash import BashBuilder
|
| 245 |
-
builder = BashBuilder()
|
| 246 |
-
errors = builder.validate({"command": command})
|
| 247 |
-
if errors:
|
| 248 |
-
return JSONResponse(content={"error": "; ".join(errors)})
|
| 249 |
-
invocation = builder.create_invocation({"command": command})
|
| 250 |
-
result = invocation.execute()
|
| 251 |
-
return JSONResponse(content=result.to_dict())
|
| 252 |
-
|
| 253 |
-
# ── Web search ─────────────────────────────────────────────────────
|
| 254 |
-
|
| 255 |
-
@app.post("/api/web_search")
|
| 256 |
-
async def web_search(request: Request):
|
| 257 |
-
body = await request.json()
|
| 258 |
-
query = body.get("query", "")
|
| 259 |
-
from ..tools.web import WebSearchBuilder
|
| 260 |
-
builder = WebSearchBuilder()
|
| 261 |
-
invocation = builder.create_invocation({"query": query})
|
| 262 |
-
result = invocation.execute()
|
| 263 |
-
return JSONResponse(content=result.to_dict())
|
| 264 |
-
|
| 265 |
-
# ── MCP status ─────────────────────────────────────────────────────
|
| 266 |
-
|
| 267 |
-
@app.get("/api/mcp_status")
|
| 268 |
-
async def mcp_status():
|
| 269 |
-
return _json(get_mcp_manager().get_status())
|
| 270 |
-
|
| 271 |
-
# ── MCP catalog (available servers to enable) ──────────────────────
|
| 272 |
-
|
| 273 |
-
@app.get("/api/mcp_catalog")
|
| 274 |
-
async def mcp_catalog():
|
| 275 |
-
"""Return all available MCP servers from the catalog, with their current status."""
|
| 276 |
-
manager = get_mcp_manager()
|
| 277 |
-
status = manager.get_status()
|
| 278 |
-
catalog = []
|
| 279 |
-
for server in MCP_CATALOG:
|
| 280 |
-
key = server["key"]
|
| 281 |
-
s = status.get(key, {"connected": False, "tool_count": 0})
|
| 282 |
-
catalog.append({
|
| 283 |
-
"key": key,
|
| 284 |
-
"name": server["name"],
|
| 285 |
-
"description": server["description"],
|
| 286 |
-
"category": server["category"],
|
| 287 |
-
"icon": server.get("icon", ""),
|
| 288 |
-
"transport": server["transport"],
|
| 289 |
-
"command": server.get("command", ""),
|
| 290 |
-
"args": server.get("args", []),
|
| 291 |
-
"connected": s.get("connected", False),
|
| 292 |
-
"enabled": key in status,
|
| 293 |
-
"tool_count": s.get("tool_count", 0),
|
| 294 |
-
"install_hint": server.get("install_hint", ""),
|
| 295 |
-
"repo_url": server.get("repo_url", ""),
|
| 296 |
-
})
|
| 297 |
-
return JSONResponse(content={"servers": catalog})
|
| 298 |
-
|
| 299 |
-
# ── MCP toggle (enable/disable a server) ───────────────────────────
|
| 300 |
-
|
| 301 |
-
@app.post("/api/mcp_toggle")
|
| 302 |
-
async def mcp_toggle(request: Request):
|
| 303 |
-
body = await request.json()
|
| 304 |
-
server_key = body.get("server_key", "")
|
| 305 |
-
enabled = body.get("enabled", False)
|
| 306 |
-
|
| 307 |
-
# Find in catalog
|
| 308 |
-
server_cfg = None
|
| 309 |
-
for s in MCP_CATALOG:
|
| 310 |
-
if s["key"] == server_key:
|
| 311 |
-
server_cfg = s
|
| 312 |
-
break
|
| 313 |
-
|
| 314 |
-
if not server_cfg:
|
| 315 |
-
return JSONResponse(content={"error": f"Unknown MCP server: {server_key}"})
|
| 316 |
-
|
| 317 |
-
manager = get_mcp_manager()
|
| 318 |
-
|
| 319 |
-
if not enabled:
|
| 320 |
-
# Disable: remove from manager
|
| 321 |
-
manager.remove_server(server_key)
|
| 322 |
-
return JSONResponse(content={"status": "disabled", "server": server_key})
|
| 323 |
-
else:
|
| 324 |
-
# Enable: add and connect
|
| 325 |
-
from ..config import MCPServerConfig
|
| 326 |
-
config = MCPServerConfig(
|
| 327 |
-
name=server_key,
|
| 328 |
-
transport=server_cfg["transport"],
|
| 329 |
-
command=server_cfg.get("command"),
|
| 330 |
-
args=server_cfg.get("args", []),
|
| 331 |
-
env=server_cfg.get("env", {}),
|
| 332 |
-
enabled=True,
|
| 333 |
-
)
|
| 334 |
-
manager.add_server(config)
|
| 335 |
-
error = None
|
| 336 |
-
if config.transport == "stdio" and config.command:
|
| 337 |
-
try:
|
| 338 |
-
error = manager.connect_all().get(server_key)
|
| 339 |
-
except Exception as e:
|
| 340 |
-
error = str(e)
|
| 341 |
-
return JSONResponse(content={
|
| 342 |
-
"status": "enabled" if not error else "error",
|
| 343 |
-
"server": server_key,
|
| 344 |
-
"error": error,
|
| 345 |
-
})
|
| 346 |
-
|
| 347 |
-
# ── MCP connect all ────────────────────────────────────────────────
|
| 348 |
-
|
| 349 |
-
@app.post("/api/mcp_connect_all")
|
| 350 |
-
async def mcp_connect_all():
|
| 351 |
-
manager = get_mcp_manager()
|
| 352 |
-
results = manager.connect_all()
|
| 353 |
-
return JSONResponse(content={"results": results})
|
| 354 |
-
|
| 355 |
-
# ── Policy status ──────────────────────────────────────────────────
|
| 356 |
-
|
| 357 |
-
@app.get("/api/policy_status")
|
| 358 |
-
async def policy_status():
|
| 359 |
-
engine = get_policy_engine()
|
| 360 |
-
return JSONResponse(content={"mode": engine.approval_mode, "rules": engine.get_rules()})
|
| 361 |
-
|
| 362 |
-
# ── Reset session ──────────────────────────────────────────────────
|
| 363 |
-
|
| 364 |
-
@app.post("/api/reset_session")
|
| 365 |
-
async def reset_session(request: Request):
|
| 366 |
-
body = await request.json()
|
| 367 |
-
session_id = body.get("session_id", "")
|
| 368 |
-
if session_id in _sessions:
|
| 369 |
-
_sessions[session_id].clear_history()
|
| 370 |
-
return JSONResponse(content={"success": True})
|
| 371 |
-
|
| 372 |
-
# ── Streaming chat ─────────────────────────────────────────────────
|
| 373 |
-
|
| 374 |
-
@app.post("/chat")
|
| 375 |
-
async def chat_stream(request: Request):
|
| 376 |
-
"""Streaming chat — returns newline-delimited JSON via StreamingResponse."""
|
| 377 |
-
try:
|
| 378 |
-
body = await request.json()
|
| 379 |
-
except Exception:
|
| 380 |
-
body = {}
|
| 381 |
-
|
| 382 |
-
message = body.get("message", "")
|
| 383 |
-
session_id = body.get("session_id", "")
|
| 384 |
-
image_url = body.get("image_url")
|
| 385 |
-
|
| 386 |
-
if not message or not message.strip():
|
| 387 |
-
return StreamingResponse(
|
| 388 |
-
iter([json.dumps({"type": "error", "error": "Empty message"}) + "\n"]),
|
| 389 |
-
media_type="text/event-stream",
|
| 390 |
-
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 391 |
-
)
|
| 392 |
-
|
| 393 |
-
sid, session = _get_or_create_session(session_id)
|
| 394 |
-
|
| 395 |
-
def event_generator():
|
| 396 |
-
try:
|
| 397 |
-
for event in session.run(message, session_id=sid, image_url=image_url):
|
| 398 |
-
yield json.dumps(event, ensure_ascii=False) + "\n"
|
| 399 |
-
except Exception as e:
|
| 400 |
-
logger.error(f"Chat stream error: {e}", exc_info=True)
|
| 401 |
-
yield json.dumps({"type": "error", "error": str(e)}) + "\n"
|
| 402 |
-
|
| 403 |
-
return StreamingResponse(
|
| 404 |
-
event_generator(),
|
| 405 |
-
media_type="text/event-stream",
|
| 406 |
-
headers={
|
| 407 |
-
"Cache-Control": "no-cache",
|
| 408 |
-
"X-Accel-Buffering": "no",
|
| 409 |
-
"Connection": "keep-alive",
|
| 410 |
-
},
|
| 411 |
-
)
|
| 412 |
-
|
| 413 |
-
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sonicoder/skills/__init__.py
DELETED
|
File without changes
|
sonicoder/tools/__init__.py
DELETED
|
@@ -1,215 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,143 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,508 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,163 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,568 +0,0 @@
|
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|