xiaoli-cli 7.0.0: 重构底层架构 + 移除工具截断

- 重构 core/ 底层 (7文件): app/runner/engine/parser/history/safety/display
- FC原生调用为主,prompt-JSON保底
- 移除所有工具返回截断 (ai_search/git_tools/cmd_executor/browser_auto/network_tools/sub_agent/tool_search)
- 删除无用文件 (旧服务器/WebUI/彩蛋/启动器等21个文件)
- 清理 constants.py 死代码
- 版本号更新为 7.0.0
This commit is contained in:
shuiyu1123 committed 2026-07-05 13:17:27 +08:00
commit 78fea7ccdd
57 files changed
+18358

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
"""xiaoli-cli 3.0.0 核心"""
from .app import XiaoliCLI
+274
View File
@@ -0,0 +1,274 @@
"""xiaoli-cli 7.0.0 主类"""
import os, sys, json, importlib
from datetime import datetime
from .types import ToolCall, ToolResult
from .history import History
from .safety import Safety
from .display import Display
from .engine import EngineAdapter
from .runner import Runner
class XiaoliCLI:
def __init__(self):
self.shared_conversation_history = []
self.max_history = 999999
self.current_engine = None
self.engines = {}
self.engine_name = "openai"
self.liugin_manager = None
self.tools = []
self.liugin_commands = {}
self.chat_dir = "chat_history"
self.memory_manager = None
self._load_config()
self._load_engines()
self._load_plugins()
self._init_memory()
self._init_core()
self._register_commands()
print("xiaoli-cli 7.0.0 初始化完成")
def _load_config(self):
try:
from xcli_core.config import get_system_config
self.engine_name = get_system_config("default_engine", "openai")
except Exception:
self.engine_name = "openai"
def _load_engines(self):
print("正在加载AI引擎...")
d = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ai_engines")
if not os.path.isdir(d):
return
for fn in os.listdir(d):
if fn.endswith("_engine.py") and not fn.startswith("__"):
mn = fn[:-3]
try:
mod = importlib.import_module(f"ai_engines.{mn}")
for an in dir(mod):
obj = getattr(mod, an)
if isinstance(obj, type) and an.endswith("AI") and hasattr(obj, "generate_response"):
eng = obj()
eng.cli = self
eng.shared_conversation_history = self.shared_conversation_history
self.engines[eng.name] = eng
print(f" 已加载: {eng.name} ({an})")
except Exception as e:
print(f" [ERROR] {mn}: {e}")
if self.engine_name not in self.engines and self.engines:
self.engine_name = list(self.engines.keys())[0]
self.current_engine = self.engines.get(self.engine_name)
print(f" 共 {len(self.engines)} 个引擎,当前: {self.engine_name}")
def _load_plugins(self):
print("正在加载插件...")
try:
from unified_tool_manager import UnifiedToolManager
self.liugin_manager = UnifiedToolManager(self)
pd = os.path.join(os.path.dirname(os.path.dirname(__file__)), "plugins")
sd = os.path.join(os.path.dirname(os.path.dirname(__file__)), "skills")
self.liugin_manager.initialize(pd, sd)
self.tools = self.liugin_manager.tools if hasattr(self.liugin_manager, "tools") else []
print(f" 已加载 {len(self.tools)} 个插件")
except Exception as e:
print(f" [ERROR] 插件加载失败: {e}")
import traceback; traceback.print_exc()
def _init_memory(self):
try:
from xcli_core.memory import MemoryManager
self.memory_manager = MemoryManager(self)
except Exception:
pass
def _init_core(self):
self.history = History(self.max_history)
self.safety = Safety(self, lambda: self.current_engine)
self.display = Display()
self.engine_adapter = EngineAdapter(self.current_engine)
self.runner = Runner(self.engine_adapter, self.liugin_manager,
self.history, self.safety, self.display)
self.runner.set_prompt_builder(self._build_prompt)
def _build_prompt(self):
p = '你叫小狸,是一个强大的智能编程助手。\n\n'
p += '【工具调用】\n当你需要执行操作时,直接调用工具。\n工具执行后,根据结果继续回复或调用下一个工具。\n\n'
p += '【补充回复】\n如果你需要补充之前的回复,输出:\n'
p += '{"action": "continue", "content": "要补充的内容"}\n\n'
p += '【核心工具】\n'
p += '1. code_editor - 代码编辑\n2. code_search - 代码搜索\n3. git_tools - Git\n'
p += '4. cmd_executor - Shell命令\n5. file_manager - 文件管理\n6. browser_auto - 浏览器\n'
p += '7. sub_agent - 子Agent\n8. auto_engineer - 工程化\n9. network_tools - 网络\n'
p += '10. ai_search - 网页内容 (url/search/web)\n\n'
p += '说话时可适当加nyan表现可爱性格。'
return p
def register_liugin_command(self, name, handler):
self.liugin_commands[name] = handler
def process_conversation(self, user_input):
return self.runner.run(user_input)
def switch_engine(self, name):
if name in self.engines:
self.engine_name = name
self.current_engine = self.engines[name]
self.current_engine.shared_conversation_history = self.shared_conversation_history
self.engine_adapter = EngineAdapter(self.current_engine)
self.runner.set_engine(self.engine_adapter)
self.safety._engine = lambda: self.current_engine
try:
from xcli_core.config import set_system_config
set_system_config("default_engine", name)
except Exception:
pass
return f"已切换到: {name}"
return f"引擎 {name} 不存在"
def _register_commands(self):
self._commands = {
"/help": self._cmd_help,
"/engine": self._cmd_engine,
"/mode": self._cmd_mode,
"/clear": self._cmd_clear,
"/history": self._cmd_history,
"/tools": self._cmd_tools,
"/exit": self._cmd_exit,
"/quit": self._cmd_exit,
"/save": self._cmd_save,
"/config": self._cmd_config,
"/new": self._cmd_new,
}
def handle_command(self, user_input):
parts = user_input.strip().split(None, 1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
h = self._commands.get("/" + cmd)
if h:
return h(args)
if cmd in self.liugin_commands:
try:
r = self.liugin_commands[cmd](args)
if r: print(r)
except Exception as e:
print(f"命令失败: {e}")
return ""
return None
def _cmd_help(self, a):
return "\n".join(["/help - 帮助", "/engine [list|<name>] - 引擎",
"/mode [0-3] - 安全模式", "/clear - 清空历史",
"/history - 历史", "/tools - 工具", "/save - 保存",
"/new - 新对话", "/config - 配置", "/exit - 退出"])
def _cmd_engine(self, a):
if not a or a == "list":
lines = [f"当前: {self.engine_name}", "可用:"]
for n in self.engines:
lines.append(f" {n}{' <==' if n == self.engine_name else ''}")
return "\n".join(lines)
return self.switch_engine(a)
def _cmd_mode(self, a):
if not a: return f"当前模式: {self.safety.get_mode()}"
try:
m = int(a)
self.safety.set_mode(m)
names = {0: "无限制", 1: "普通", 2: "人工", 3: "计划"}
return f"已切换: {names.get(m, '未知')}"
except ValueError:
return "用法: /mode [0-3]"
def _cmd_clear(self, a):
self.history.clear()
self.shared_conversation_history.clear()
return "已清空"
def _cmd_history(self, a):
msgs = self.history.snapshot()
if not msgs: return "无历史"
lines = [f"共 {len(msgs)} 条:"]
for m in msgs[-10:]:
c = m.content[:50] + "..." if len(m.content) > 50 else m.content
lines.append(f" [{m.role}] {c}")
return "\n".join(lines)
def _cmd_tools(self, a):
if not self.tools: return "无工具"
lines = [f"共 {len(self.tools)} 个:"]
for t in self.tools:
lines.append(f" {t.get('name','')}: {t.get('description','')[:60]}")
return "\n".join(lines)
def _cmd_save(self, a):
try:
os.makedirs(self.chat_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
fp = os.path.join(self.chat_dir, f"{ts}_chat.json")
msgs = self.history.snapshot()
data = [{"role": m.role, "content": m.content} for m in msgs]
with open(fp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return f"已保存: {fp}"
except Exception as e:
return f"失败: {e}"
def _cmd_new(self, a):
self._cmd_save("")
self.history.clear()
self.shared_conversation_history.clear()
return "新对话"
def _cmd_config(self, a):
try:
with open("config.json", "r", encoding="utf-8") as f:
return json.dumps(json.load(f), ensure_ascii=False, indent=2)[:1000]
except Exception as e:
return f"失败: {e}"
def _cmd_exit(self, a):
self._cmd_save("")
return "__EXIT__"
def run(self):
try:
from colorama import init, Fore, Style
init()
except ImportError:
class _D:
CYAN = GREEN = YELLOW = RESET_ALL = ''
Fore = Style = _D()
print()
print(f" {Fore.CYAN}+========================================+{Style.RESET_ALL}")
print(f" {Fore.CYAN}| xiaoli-cli 7.0.0 (重构版) |{Style.RESET_ALL}")
print(f" {Fore.CYAN}| 小狸 AI 编程助手 |{Style.RESET_ALL}")
print(f" {Fore.CYAN}+========================================+{Style.RESET_ALL}")
print()
print(f" 引擎: {Fore.GREEN}{self.engine_name}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
print(f" 工具: {Fore.GREEN}{len(self.tools)}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''} 个插件")
print(f" FC: {'支持' if self.engine_adapter.supports_fc() else '不支持'}")
print(f" 输入 /help 查看命令")
print()
while True:
try:
ui = input(f"{Fore.CYAN}[用户ID: xiaoli]{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''} ").strip()
if not ui: continue
if ui.startswith("/"):
r = self.handle_command(ui)
if r == "__EXIT__":
print(f" {Fore.YELLOW}再见~{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
break
if r: print(r)
continue
self.process_conversation(ui)
print()
except KeyboardInterrupt:
print(f"\n {Fore.YELLOW}(Ctrl+C, /exit退出){Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
except EOFError:
break
except Exception as e:
print(f" {Fore.RED}错误: {e}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
+44
View File
@@ -0,0 +1,44 @@
"""显示输出"""
import sys
try:
from colorama import Fore, Style, init
init()
except ImportError:
class _D:
CYAN = GREEN = YELLOW = RED = LIGHTBLACK_EX = RESET_ALL = ''
Fore = Style = _D()
class Display:
def text(self, text, is_continue=False):
if text:
print(text)
def thinking(self, text):
if text:
t = text[:500] + "..." if len(text) > 500 else text
print(f"{Style.LIGHTBLACK_EX if hasattr(Style,'LIGHTBLACK_EX') else Fore.LIGHTBLACK_EX}[深度思考: {t}]{Style.RESET_ALL}")
def tool_start(self, tc):
args = tc.args[:80] + "..." if len(tc.args) > 80 else tc.args
print(f" {Fore.GREEN}OK 工具:{Fore.RESET_ALL if hasattr(Fore,'RESET_ALL') else Style.RESET_ALL} {tc.name}")
print(f" {Fore.CYAN}参数:{Style.RESET_ALL} {args}")
def tool_result(self, tc, result):
text = result.data if result.data is not None else result.error_message
if text:
s = str(text)
if len(s) > 2000:
lines = s.split("\n")
if len(lines) > 30:
s = "\n".join(lines[:30]) + f"\n... (共{len(lines)}行)"
print(f" {Fore.CYAN}结果:{Style.RESET_ALL}")
for line in s.split("\n"):
print(f" {line}")
def info(self, msg):
print(f"{Fore.YELLOW}{msg}{Style.RESET_ALL}")
def error(self, msg):
print(f"{Fore.RED}{msg}{Style.RESET_ALL}")
+170
View File
@@ -0,0 +1,170 @@
"""引擎适配 — FC为主,prompt-JSON保底"""
import json, re, uuid
from .types import EngineResponse, ToolCall
class EngineAdapter:
def __init__(self, engine):
self._engine = engine
self._fc = hasattr(engine, 'generate_with_tools')
def supports_fc(self):
return self._fc
def name(self):
return getattr(self._engine, 'name', 'unknown')
def generate(self, messages, tools=None, system_prompt=None):
if self._fc and tools:
return self._gen_fc(messages, tools, system_prompt)
return self._gen_legacy(messages, system_prompt)
def _gen_fc(self, messages, tools, system_prompt):
try:
raw = self._engine.generate_with_tools(messages, tools, system_prompt=system_prompt)
text = ""
thinking = ""
tool_calls = None
if isinstance(raw, dict):
text = raw.get("content", "") or ""
thinking = raw.get("reasoning_content", "") or ""
tc_list = raw.get("tool_calls", []) or []
if tc_list:
tool_calls = []
for tc in tc_list:
func = tc.get("function", {})
cid = tc.get("id", str(uuid.uuid4())[:8])
name = func.get("name", "")
args_str = func.get("arguments", "{}")
arguments = None
try:
if isinstance(args_str, str) and args_str:
arguments = json.loads(args_str)
except json.JSONDecodeError:
pass
args = self._to_legacy(name, arguments) if arguments else (args_str or "")
tool_calls.append(ToolCall(id=cid, name=name, args=args,
arguments=arguments, raw=tc))
return EngineResponse(text=text, thinking=thinking, tool_calls=tool_calls, raw=raw)
except Exception as e:
return EngineResponse(text=f"引擎调用失败: {e}")
def _gen_legacy(self, messages, system_prompt):
user_input = ""
for msg in reversed(messages):
if msg.get("role") == "user":
user_input = msg.get("content", "")
break
try:
resp = self._engine.generate_response(user_input, system_prompt=system_prompt)
except Exception as e:
return EngineResponse(text=f"引擎调用失败: {e}")
if not resp:
return EngineResponse()
text, thinking = self._strip_thinking(resp)
return EngineResponse(text=text, thinking=thinking, raw=resp)
def _strip_thinking(self, text):
if not text:
return "", ""
thinking = ""
if "<thinking>" in text and "</thinking>" in text:
m = re.search(r'<thinking>(.*?)</thinking>(.*)', text, re.DOTALL)
if m:
thinking = m.group(1).strip()
text = m.group(2).strip()
start = getattr(self._engine, 'thinking_start_marker', None)
end = getattr(self._engine, 'thinking_end_marker', None)
if start and end and start in text and end in text:
parts = text.split(start)
if len(parts) >= 2:
rest = parts[1]
if end in rest:
tp, rp = rest.split(end, 1)
thinking = (thinking + "\n" + tp.strip()).strip() if thinking else tp.strip()
text = (parts[0] + rp).strip()
return text, thinking
def _to_legacy(self, tool_name, args):
"""FC dict参数 → 旧插件字符串参数"""
if not args:
return ""
if tool_name == "ai_search":
url = args.get("url", "")
q = args.get("query", args.get("search", ""))
if url: return f"url {url}"
if q: return f"search {q}"
if tool_name == "network_tools":
op = args.get("operation", args.get("op", "get"))
t = args.get("target", args.get("url", args.get("host", "")))
if op and t: return f"{op} {t}"
if tool_name == "code_editor":
op = args.get("operation", args.get("action", ""))
fp = args.get("file", args.get("path", args.get("filename", "")))
ct = args.get("content", args.get("text", args.get("code", "")))
old = args.get("old", args.get("old_code", args.get("find", "")))
new = args.get("new", args.get("new_code", args.get("replace", "")))
ln = args.get("line", args.get("line_number", ""))
sl = args.get("start", args.get("start_line", ""))
el = args.get("end", args.get("end_line", ""))
pat = args.get("pattern", args.get("file_pattern", ""))
d = args.get("directory", args.get("dir", "."))
if op == "create" and fp and ct: return f"create {fp}\n{ct}"
if op == "write" and fp and ct: return f"write {fp}\n{ct}"
if op == "append" and fp and ct: return f"append {fp}\n{ct}"
if op in ("read_range", "read"): return f"read_range {fp} {sl or 1} {el or 50}"
if op == "edit": return f"edit {fp}\n{old}\n<<<>>>\n{new}"
if op == "insert": return f"insert {fp} {ln}\n{ct}"
if op in ("find", "search"): return f"find {pat} {d}"
if op == "syntax_check": return f"syntax_check {fp}"
return json.dumps(args, ensure_ascii=False)
if tool_name == "code_search":
op = args.get("operation", "find")
pat = args.get("pattern", args.get("query", args.get("search", "")))
d = args.get("directory", args.get("dir", "."))
return f"{op} {pat} {d}"
if tool_name == "git_tools":
op = args.get("operation", "status")
a = args.get("args", "")
return f"{op} {a}" if a else op
if tool_name == "cmd_executor":
return args.get("command", args.get("cmd", args.get("shell", "")))
if tool_name == "file_manager":
op = args.get("operation", args.get("action", "list"))
p = args.get("path", args.get("file", "."))
ct = args.get("content", args.get("text", ""))
if op in ("read", "list", "info"): return f"{op} {p}"
if op == "write" and ct: return f'write {p} -c "{ct}"'
if op == "append" and ct: return f'append {p} -c "{ct}"'
if op and p: return f"{op} {p}"
if tool_name == "browser_auto":
op = args.get("operation", "")
url = args.get("url", "")
if op == "open" and url: return f"open {url}"
if tool_name == "auto_engineer":
op = args.get("operation", "")
t = args.get("target", args.get("path", ""))
if op and t: return f"{op} {t}"
return op
if tool_name == "sub_agent":
op = args.get("operation", "list")
if op == "create":
task = args.get("task", args.get("prompt", ""))
return f"create {task}"
return op
if tool_name in ("task_manager", "scheduler"):
return args.get("operation", "list")
# 通用
if "operation" in args:
op = args["operation"]
rest = " ".join(str(v) for k, v in args.items() if k != "operation")
return f"{op} {rest}".strip()
return " ".join(str(v) for v in args.values())
def handle_command(self, cmd):
if hasattr(self._engine, 'handle_command'):
return self._engine.handle_command(cmd)
return False
def get_raw(self):
return self._engine
+84
View File
@@ -0,0 +1,84 @@
"""线程安全对话历史"""
import json
import threading
from .types import Message
class History:
def __init__(self, max_size=999999):
self._msgs = []
self._lock = threading.RLock()
self._max = max_size
def add_user(self, text):
with self._lock:
self._msgs.append(Message("user", text))
self._trim()
def add_assistant(self, text, tool_calls=None):
with self._lock:
m = Message("assistant", text)
if tool_calls:
m.tool_calls = tool_calls
self._msgs.append(m)
self._trim()
def add_tool(self, call_id, name, result):
with self._lock:
self._msgs.append(Message("tool", result, tool_call_id=call_id, name=name))
self._trim()
def add_cancel(self, name, args):
with self._lock:
self._msgs.append(Message("user", f"工具 {name} 已取消。参数: {args}"))
self._trim()
def _trim(self):
if len(self._msgs) > self._max:
self._msgs = self._msgs[-self._max:]
def render(self, system_prompt="", current=None, fc=False):
"""渲染为消息列表。fc=True 用 role:tool,否则工具结果用 role:user"""
with self._lock:
result = []
if system_prompt:
result.append({"role": "system", "content": system_prompt})
for m in self._msgs:
if m.role == "tool" and not fc:
result.append({"role": "user", "content": m.content})
else:
entry = {"role": m.role, "content": m.content}
if fc and m.role == "tool" and m.tool_call_id:
entry["tool_call_id"] = m.tool_call_id
if m.name:
entry["name"] = m.name
if fc and m.role == "assistant" and m.tool_calls:
# content 为空时设为 None(OpenAI 规范:有 tool_calls 时 content 可为 null)
if not entry.get("content"):
entry["content"] = None
# arguments 必须是 JSON 字符串(OpenAI 规范)
entry["tool_calls"] = [
{"id": tc.id, "type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False) if tc.arguments
else json.dumps({"args": tc.args}, ensure_ascii=False)
}}
for tc in m.tool_calls
]
result.append(entry)
if current:
result.append({"role": "user", "content": current})
return result
def snapshot(self):
with self._lock:
return list(self._msgs)
def clear(self):
with self._lock:
self._msgs.clear()
def __len__(self):
with self._lock:
return len(self._msgs)
+146
View File
@@ -0,0 +1,146 @@
"""JSON解析 — 保底方案"""
import json, re, uuid
from .types import ToolCall, Plan
def parse_prompt_json(text):
"""解析prompt-JSON,返回 (text_content, tool_calls_or_None)"""
text = text.strip()
json_str = _extract_json(text)
if json_str is None:
return text, None
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return text, None
idx = text.find(json_str)
prefix = text[:idx].strip() if idx > 0 else ""
if isinstance(data, list):
calls = [c for c in (_make(c) for c in data) if c]
return prefix, calls if calls else None
if isinstance(data, dict):
c = _make(data)
if c:
return prefix, [c]
return prefix, data
return text, None
def _extract_json(text):
"""平衡括号扫描"""
depth = 0
start = -1
in_str = False
esc = False
for i, c in enumerate(text):
if esc:
esc = False
continue
if c == '\\' and in_str:
esc = True
continue
if c == '"' and not esc:
in_str = not in_str
continue
if in_str:
continue
if c == '{':
if depth == 0:
start = i
depth += 1
elif c == '}':
depth -= 1
if depth == 0 and start != -1:
candidate = text[start:i+1]
if '"action"' in candidate or '"tool"' in candidate:
return candidate
start = -1
elif c == '[' and depth == 0:
bd = 1
j = i + 1
in_s = False
e2 = False
while j < len(text) and bd > 0:
cj = text[j]
if e2:
e2 = False
elif cj == '\\' and in_s:
e2 = True
elif cj == '"' and not e2:
in_s = not in_s
elif not in_s:
if cj == '[': bd += 1
elif cj == ']': bd -= 1
j += 1
if bd == 0:
candidate = text[i:j]
if '"action"' in candidate or '"tool"' in candidate:
return candidate
return None
def _make(data):
"""dict → ToolCall"""
if not isinstance(data, dict):
return None
cid = data.get("id", str(uuid.uuid4())[:8])
name = ""
args = ""
arguments = None
if data.get("action") == "use_tool":
name = data.get("tool", data.get("name", ""))
ra = data.get("args", data.get("arguments", ""))
if isinstance(ra, str): args = ra
elif isinstance(ra, dict):
arguments = ra
args = json.dumps(ra, ensure_ascii=False)
else: args = str(ra)
elif "name" in data and "arguments" in data:
name = data["name"]
ra = data["arguments"]
if isinstance(ra, str):
args = ra
try: arguments = json.loads(ra)
except: pass
elif isinstance(ra, dict):
arguments = ra
args = json.dumps(ra, ensure_ascii=False)
elif "tool" in data and "args" in data:
name = data["tool"]
ra = data["args"]
if isinstance(ra, str): args = ra
elif isinstance(ra, dict):
arguments = ra
args = json.dumps(ra, ensure_ascii=False)
else:
return None
if not name:
return None
return ToolCall(id=cid, name=name, args=args, arguments=arguments, raw=data)
def interpret(resp, fc_mode):
"""EngineResponse → Plan"""
# FC模式
if fc_mode and resp.tool_calls:
return Plan(action="use_tool", text=resp.text or "", tool_calls=resp.tool_calls)
# prompt-JSON
text, parsed = parse_prompt_json(resp.text)
if parsed is None:
return Plan(action="final", text=resp.text)
if isinstance(parsed, list):
return Plan(action="use_tool", text=text, tool_calls=parsed)
if isinstance(parsed, dict):
action = parsed.get("action", "")
if action == "use_tool":
c = _make(parsed)
if c:
return Plan(action="use_tool", text=text, tool_calls=[c])
return Plan(action="final", text=resp.text)
if action == "continue" or parsed.get("continue"):
msg = parsed.get("content", parsed.get("message", ""))
return Plan(action="continue", text=msg or text)
if "message" in parsed:
return Plan(action="final", text=parsed["message"])
return Plan(action="final", text=text or resp.text)
return Plan(action="final", text=resp.text)
+146
View File
@@ -0,0 +1,146 @@
"""对话循环 — 唯一循环,无重复"""
import threading
from datetime import datetime
from .types import ToolCall, ToolResult
from xcli_core.tool_result import ErrorCode
from .parser import interpret
class Runner:
def __init__(self, engine, manager, history, safety, display):
self._engine = engine # EngineAdapter
self._mgr = manager # UnifiedToolManager
self._history = history # History
self._safety = safety # Safety
self._display = display # Display
self._cancel = threading.Event()
self._max_rounds = 50
self._prompt_builder = None
def set_prompt_builder(self, b):
self._prompt_builder = b
def set_engine(self, e):
self._engine = e
def run(self, user_input):
self._history.add_user(user_input)
self._cancel.clear()
return self._loop(user_input)
def _loop(self, seed):
rounds = 0
current = seed
fc = self._engine.supports_fc()
final_text = ""
while rounds < self._max_rounds:
rounds += 1
if self._cancel.is_set():
self._display.info("(用户取消)")
break
# 系统提示词
sp = self._prompt_builder() if self._prompt_builder else ""
if not fc:
sp = (sp + "\n" + self._mgr.get_tools_prompt()) if sp else self._mgr.get_tools_prompt()
# 生成
if fc:
msgs = self._history.render(sp, current, fc=True)
tools = self._get_tools()
else:
msgs = self._history.render(sp, current, fc=False)
tools = None
resp = self._engine.generate(msgs, tools, sp)
if resp.thinking:
self._display.thinking(resp.thinking)
# 空响应
if not resp.text and not resp.tool_calls:
break
# 解释
plan = interpret(resp, fc)
# 显示+历史
if plan.text:
self._history.add_assistant(plan.text)
self._display.text(plan.text)
if plan.action == "final":
final_text = plan.text
break
if plan.action == "continue":
current = None
continue
if plan.action == "use_tool":
results = self._exec_tools(plan.tool_calls, fc)
if results is None:
break
current = None
continue
else:
self._display.info(f"(达到最大轮数{self._max_rounds})")
return final_text
def _exec_tools(self, calls, fc):
if self._cancel.is_set():
for tc in calls:
self._history.add_cancel(tc.name, tc.args)
return None
results = []
for tc in calls:
r = self._exec_one(tc)
results.append((tc, r))
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if fc:
self._history.add_assistant("", tool_calls=calls)
for tc, r in results:
text = r.data if r.data is not None else (r.error_message or "")
self._history.add_tool(tc.id, tc.name, str(text or ""))
else:
if len(results) == 1:
tc, r = results[0]
text = r.data if r.data is not None else (r.error_message or "")
self._history.add_user(f"工具调用结果 (时间: {now}):\n{text}")
else:
parts = [f"工具{i+1}结果:\n{r.data if r.data is not None else (r.error_message or '')}"
for i, (tc, r) in enumerate(results)]
self._history.add_user(f"批量工具结果 (时间: {now}):\n" + "\n".join(parts))
return results
def _exec_one(self, tc):
if self._cancel.is_set():
return ToolResult.fail("用户取消", code=ErrorCode.NOT_IMPLEMENTED, tool_name=tc.name)
allowed, reason = self._safety.check(tc)
if not allowed:
return ToolResult.fail(f"被拒绝: {reason}", code=ErrorCode.PERMISSION_DENIED, tool_name=tc.name)
self._display.tool_start(tc)
r = self._mgr.execute(tc.name, tc.args)
self._display.tool_result(tc, r)
return r
def _get_tools(self):
"""生成FC工具定义"""
defs = []
for tool in self._mgr.tools:
mcp = tool.get("mcp_definition")
if mcp:
defs.append({"type": "function", "function": {
"name": mcp.get("name", tool.get("name", "")),
"description": mcp.get("description", tool.get("description", "")),
"parameters": mcp.get("inputSchema", {"type": "object", "properties": {}})
}})
else:
defs.append({"type": "function", "function": {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"parameters": {"type": "object", "properties": {}}
}})
return defs
+137
View File
@@ -0,0 +1,137 @@
"""安全检查"""
import json, re, threading
from .types import ToolCall
MODE_UNRESTRICTED = 0
MODE_NORMAL = 1
MODE_MANUAL = 2
MODE_PLAN = 3
WHITELIST = {
"code_editor": ["read_range", "find ", "regex ", "symbols", "imports", "stats",
"structure", "context", "todo", "diff "],
"git_tools": ["status", "log", "diff", "branch", "remote", "show", "blame"],
"file_manager": ["list", "read", "info", "search"],
"ai_search": ["url ", "search ", "web "],
"network_tools": ["ping", "status", "headers"],
"code_search": ["find", "regex", "symbols"],
"task_manager": ["list"],
"sub_agent": ["list", "status"],
"auto_engineer": ["metrics", "security", "complexity", "suggest"],
}
class Safety:
def __init__(self, cli=None, engine_provider=None):
self._cli = cli
self._engine = engine_provider or (lambda: None)
self._lock = threading.Lock()
self._cache = {}
try:
from xcli_core.config import get_system_config
self.mode = get_system_config("safety_mode", MODE_NORMAL)
except Exception:
self.mode = MODE_NORMAL
def check(self, tool_call):
"""返回 (allowed, reason)"""
# 白名单
patterns = WHITELIST.get(tool_call.name, [])
args_lower = tool_call.args.strip().lower()
for p in patterns:
if args_lower.startswith(p.lower()):
return True, "白名单放行"
# 无限制/计划模式
if self.mode in (MODE_UNRESTRICTED, MODE_PLAN):
return True, "模式自动放行"
# 人工模式
if self.mode == MODE_MANUAL:
return self._confirm(tool_call)
# 普通模式:AI分析
return self._ai_analyze(tool_call)
def _ai_analyze(self, tool_call):
engine = self._engine()
if not engine:
return self._confirm(tool_call)
key = f"{tool_call.name}:{tool_call.args[:100]}"
with self._lock:
if key in self._cache:
cached = self._cache[key]
if cached.get("safe"):
return True, "AI分析通过(缓存)"
return self._confirm(tool_call)
prompt = f"""分析工具调用的安全性。只返回JSON:
{{"safe": true/false, "level": "低/中/高", "reason": "原因"}}
工具: {tool_call.name}
参数: {tool_call.args[:200]}
safe=true: 读取/查看/无害操作
safe=false: 删除/执行命令/修改配置"""
try:
resp = engine.generate_response(prompt, system_prompt="只返回JSON")
result = self._parse(resp)
if result:
with self._lock:
self._cache[key] = result
if result.get("safe") is True:
return True, "AI分析通过"
return self._confirm(tool_call)
except Exception:
pass
return self._confirm(tool_call)
def _parse(self, resp):
if not resp:
return None
resp = resp.strip()
# 去thinking
if "</thinking>" in resp:
resp = resp.split("</thinking>")[-1].strip()
# 去markdown
if resp.startswith("```"):
resp = resp.split("\n", 1)[-1] if "\n" in resp else resp[3:]
if resp.endswith("```"):
resp = resp[:-3]
try:
return json.loads(resp.strip())
except json.JSONDecodeError:
m = re.search(r'\{.*?"safe".*?\}', resp, re.DOTALL)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
return None
def _confirm(self, tool_call):
try:
from colorama import Fore, Style
except ImportError:
Fore = Style = type('D', (), {'RED': '', 'CYAN': '', 'YELLOW': '', 'RESET_ALL': ''})()
print(f" {Fore.RED}工具: {tool_call.name}{Style.RESET_ALL}")
print(f" {Fore.CYAN}参数: {tool_call.args[:100]}{Style.RESET_ALL}")
try:
choice = input(f" {Fore.YELLOW}是否执行? (y/n): {Style.RESET_ALL}").strip().lower()
allowed = choice in ('y', 'yes', '是')
return allowed, "用户确认" if allowed else "用户拒绝"
except (EOFError, KeyboardInterrupt):
return True, "非交互模式自动放行"
def get_mode(self):
return self.mode
def set_mode(self, mode):
self.mode = mode
try:
from xcli_core.config import set_system_config
set_system_config("safety_mode", mode)
except Exception:
pass
+37
View File
@@ -0,0 +1,37 @@
"""核心数据类型"""
from dataclasses import dataclass, field
from typing import Any, Optional
from xcli_core.tool_result import ToolResult
@dataclass
class ToolCall:
id: str
name: str
args: str
arguments: Optional[dict] = None
raw: dict = field(default_factory=dict)
@dataclass
class EngineResponse:
text: str = ""
thinking: str = ""
tool_calls: Optional[list] = None
raw: Any = None
@dataclass
class Plan:
action: str = "final" # final | continue | use_tool
text: str = ""
tool_calls: list = field(default_factory=list)
@dataclass
class Message:
role: str # system | user | assistant | tool
content: str
tool_call_id: Optional[str] = None
tool_calls: Optional[list] = None
name: Optional[str] = None