nexforge-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nexforge/__init__.py +7 -0
- nexforge/__main__.py +6 -0
- nexforge/app.py +38 -0
- nexforge/assets/icons/agent.svg +13 -0
- nexforge/assets/icons/cost.svg +5 -0
- nexforge/assets/icons/edit.svg +4 -0
- nexforge/assets/icons/err.svg +5 -0
- nexforge/assets/icons/file.svg +5 -0
- nexforge/assets/icons/model.svg +7 -0
- nexforge/assets/icons/ok.svg +4 -0
- nexforge/assets/icons/read.svg +8 -0
- nexforge/assets/icons/running.svg +5 -0
- nexforge/assets/icons/search.svg +5 -0
- nexforge/assets/icons/shell.svg +5 -0
- nexforge/assets/icons/thinking.svg +5 -0
- nexforge/assets/icons/web.svg +6 -0
- nexforge/assets/icons/write.svg +4 -0
- nexforge/cli.py +159 -0
- nexforge/config.py +192 -0
- nexforge/core/__init__.py +1 -0
- nexforge/core/agent.py +244 -0
- nexforge/core/jobs.py +97 -0
- nexforge/core/permissions.py +73 -0
- nexforge/core/router.py +62 -0
- nexforge/core/session.py +55 -0
- nexforge/core/types.py +103 -0
- nexforge/icons.py +173 -0
- nexforge/mcp/__init__.py +168 -0
- nexforge/provider/__init__.py +1 -0
- nexforge/provider/balance.py +37 -0
- nexforge/provider/deepseek.py +204 -0
- nexforge/skills/loader.py +102 -0
- nexforge/store/__init__.py +1 -0
- nexforge/store/plan_store.py +112 -0
- nexforge/store/session_store.py +206 -0
- nexforge/theme.tcss +128 -0
- nexforge/tools/__init__.py +6 -0
- nexforge/tools/_paths.py +22 -0
- nexforge/tools/background.py +111 -0
- nexforge/tools/base.py +56 -0
- nexforge/tools/edit_file.py +73 -0
- nexforge/tools/fs_ops.py +125 -0
- nexforge/tools/git.py +89 -0
- nexforge/tools/list_dir.py +49 -0
- nexforge/tools/multi_edit.py +84 -0
- nexforge/tools/read_file.py +51 -0
- nexforge/tools/registry.py +94 -0
- nexforge/tools/search_content.py +90 -0
- nexforge/tools/search_files.py +48 -0
- nexforge/tools/shell.py +60 -0
- nexforge/tools/web.py +40 -0
- nexforge/tools/write_file.py +40 -0
- nexforge/ui/__init__.py +1 -0
- nexforge/ui/screens/__init__.py +1 -0
- nexforge/ui/screens/chat.py +825 -0
- nexforge/ui/widgets/__init__.py +1 -0
- nexforge/ui/widgets/apikey_modal.py +65 -0
- nexforge/ui/widgets/banner.py +18 -0
- nexforge/ui/widgets/confirm_modal.py +58 -0
- nexforge/ui/widgets/diff_modal.py +67 -0
- nexforge/ui/widgets/messages.py +132 -0
- nexforge/ui/widgets/mode_warning.py +57 -0
- nexforge/ui/widgets/multiline_modal.py +59 -0
- nexforge/ui/widgets/multiline_prompt.py +25 -0
- nexforge/ui/widgets/plan_sidebar.py +89 -0
- nexforge/ui/widgets/sidebar.py +99 -0
- nexforge/ui/widgets/statusbar.py +70 -0
- nexforge/web/__init__.py +0 -0
- nexforge/web/server.py +177 -0
- nexforge/web/static/index.html +123 -0
- nexforge_cli-0.1.0.dist-info/METADATA +168 -0
- nexforge_cli-0.1.0.dist-info/RECORD +74 -0
- nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
- nexforge_cli-0.1.0.dist-info/entry_points.txt +3 -0
nexforge/core/agent.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""AgentLoop —— 智能体主循环。
|
|
2
|
+
|
|
3
|
+
以异步事件流形式产出(思考 / 正文 / 工具开始 / 工具结束 / 路由 / 完成),
|
|
4
|
+
TUI 与 one-shot 模式共用同一循环。
|
|
5
|
+
|
|
6
|
+
循环:向模型请求 → 若返回 tool_calls 则逐个执行并回灌结果 → 继续,
|
|
7
|
+
直到模型不再调用工具(与 DeepSeek 官方 tool_calls 循环一致)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from nexforge.config import NexForgeConfig
|
|
20
|
+
from nexforge.core.router import RouteDecision, Router
|
|
21
|
+
from nexforge.core.session import Session
|
|
22
|
+
from nexforge.core.types import AssistantMessage, ChunkKind
|
|
23
|
+
from nexforge.provider.deepseek import DeepSeekProvider, ProviderError
|
|
24
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
25
|
+
from nexforge.tools.registry import ToolRegistry, default_registry
|
|
26
|
+
|
|
27
|
+
# 审批回调:传入 (tool, args),返回 bool(可为协程)。None 表示自动放行。
|
|
28
|
+
ApproveFn = Callable[[BaseTool, dict], "bool | Awaitable[bool]"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentEventKind(str, Enum):
|
|
32
|
+
ROUTE = "route"
|
|
33
|
+
REASONING = "reasoning"
|
|
34
|
+
CONTENT = "content"
|
|
35
|
+
TOOL_START = "tool_start"
|
|
36
|
+
TOOL_END = "tool_end"
|
|
37
|
+
TURN_DONE = "turn_done"
|
|
38
|
+
ERROR = "error"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AgentEvent:
|
|
43
|
+
kind: AgentEventKind
|
|
44
|
+
text: str = ""
|
|
45
|
+
tool_name: str = ""
|
|
46
|
+
tool_preview: str = ""
|
|
47
|
+
tool_ok: bool = True
|
|
48
|
+
tool_summary: str = ""
|
|
49
|
+
route: RouteDecision | None = None
|
|
50
|
+
message: AssistantMessage | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _maybe_await(value: Any) -> Any:
|
|
54
|
+
if inspect.isawaitable(value):
|
|
55
|
+
return await value
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentLoop:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
config: NexForgeConfig,
|
|
63
|
+
provider: DeepSeekProvider,
|
|
64
|
+
session: Session,
|
|
65
|
+
registry: ToolRegistry | None = None,
|
|
66
|
+
approve: ApproveFn | None = None,
|
|
67
|
+
):
|
|
68
|
+
self.config = config
|
|
69
|
+
self.provider = provider
|
|
70
|
+
self.session = session
|
|
71
|
+
self.registry = registry or default_registry()
|
|
72
|
+
if config.tools.enable_git_commit:
|
|
73
|
+
from nexforge.tools.registry import register_git_commit
|
|
74
|
+
register_git_commit(self.registry)
|
|
75
|
+
self.router = Router(config)
|
|
76
|
+
self._approve = approve
|
|
77
|
+
|
|
78
|
+
async def run_turn(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
|
79
|
+
self.session.add_user(user_text)
|
|
80
|
+
tool_failures = 0
|
|
81
|
+
last_failed: tuple[str, str] = ("", "")
|
|
82
|
+
consecutive_fails = 0
|
|
83
|
+
try:
|
|
84
|
+
limit = self.config.tools.max_tool_iters
|
|
85
|
+
for _ in range(limit):
|
|
86
|
+
decision = self.router.choose(self.session, user_text, tool_failures)
|
|
87
|
+
yield AgentEvent(AgentEventKind.ROUTE, route=decision)
|
|
88
|
+
|
|
89
|
+
final_msg: AssistantMessage | None = None
|
|
90
|
+
active_tools = self._active_tools()
|
|
91
|
+
async for chunk in self.provider.stream(
|
|
92
|
+
self.session.messages,
|
|
93
|
+
tools=active_tools,
|
|
94
|
+
tier=decision.tier,
|
|
95
|
+
thinking=decision.thinking,
|
|
96
|
+
effort=decision.effort,
|
|
97
|
+
):
|
|
98
|
+
if chunk.kind is ChunkKind.REASONING:
|
|
99
|
+
yield AgentEvent(AgentEventKind.REASONING, text=chunk.text)
|
|
100
|
+
elif chunk.kind is ChunkKind.CONTENT:
|
|
101
|
+
yield AgentEvent(AgentEventKind.CONTENT, text=chunk.text)
|
|
102
|
+
elif chunk.kind is ChunkKind.DONE:
|
|
103
|
+
final_msg = chunk.message
|
|
104
|
+
|
|
105
|
+
assert final_msg is not None
|
|
106
|
+
self.session.add_assistant(final_msg)
|
|
107
|
+
|
|
108
|
+
if not final_msg.tool_calls:
|
|
109
|
+
yield AgentEvent(AgentEventKind.TURN_DONE, message=final_msg)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
for tc in final_msg.tool_calls:
|
|
113
|
+
args = self.registry.parse_args(tc.arguments)
|
|
114
|
+
tool = self.registry.get(tc.name)
|
|
115
|
+
preview = tool.preview(args) if tool else f"{tc.name}(?)"
|
|
116
|
+
yield AgentEvent(
|
|
117
|
+
AgentEventKind.TOOL_START,
|
|
118
|
+
tool_name=tc.name,
|
|
119
|
+
tool_preview=preview,
|
|
120
|
+
)
|
|
121
|
+
result = await self._exec_tool(tool, tc.name, args)
|
|
122
|
+
self.session.add_tool_result(tc.id, result.to_content())
|
|
123
|
+
if not result.ok:
|
|
124
|
+
tool_failures += 1
|
|
125
|
+
# 死循环检测:同一工具同一参数连续失败 3 次 → 注入提示
|
|
126
|
+
fail_key = (tc.name, tc.arguments)
|
|
127
|
+
if fail_key == last_failed:
|
|
128
|
+
consecutive_fails += 1
|
|
129
|
+
else:
|
|
130
|
+
last_failed, consecutive_fails = fail_key, 1
|
|
131
|
+
if consecutive_fails >= 3:
|
|
132
|
+
self.session.messages.append({
|
|
133
|
+
"role": "system",
|
|
134
|
+
"content": (
|
|
135
|
+
f"[防死循环] 工具 {tc.name} 已连续失败 {consecutive_fails} 次。"
|
|
136
|
+
"请不要重复相同调用;换一种方法或跳过当前步骤,如无法完成则向用户说明原因并结束。"
|
|
137
|
+
),
|
|
138
|
+
})
|
|
139
|
+
consecutive_fails = 0 # 复位,避免重复注入
|
|
140
|
+
else:
|
|
141
|
+
# 成功 → 重置失败跟踪
|
|
142
|
+
last_failed = ("", "")
|
|
143
|
+
consecutive_fails = 0
|
|
144
|
+
yield AgentEvent(
|
|
145
|
+
AgentEventKind.TOOL_END,
|
|
146
|
+
tool_name=tc.name,
|
|
147
|
+
tool_ok=result.ok,
|
|
148
|
+
tool_summary=result.summary or result.error or result.output[:60],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
yield AgentEvent(
|
|
152
|
+
AgentEventKind.ERROR,
|
|
153
|
+
text=f"已达工具调用轮数上限({limit}),停止本轮。",
|
|
154
|
+
)
|
|
155
|
+
except ProviderError as exc:
|
|
156
|
+
yield AgentEvent(AgentEventKind.ERROR, text=str(exc))
|
|
157
|
+
|
|
158
|
+
def _active_tools(self) -> list[dict]:
|
|
159
|
+
"""根据当前模式返回可用工具 schema(plan 模式只读)。"""
|
|
160
|
+
all_specs = self.registry.specs()
|
|
161
|
+
if not self.config.ui.plan_mode:
|
|
162
|
+
return all_specs
|
|
163
|
+
READ_ONLY = {
|
|
164
|
+
"read_file", "list_dir", "search_content", "search_files",
|
|
165
|
+
"web_fetch", "git_status", "git_diff",
|
|
166
|
+
}
|
|
167
|
+
return [s for s in all_specs
|
|
168
|
+
if s.get("function", {}).get("name") in READ_ONLY]
|
|
169
|
+
|
|
170
|
+
async def _exec_tool(
|
|
171
|
+
self, tool: BaseTool | None, name: str, args: dict
|
|
172
|
+
) -> ToolResult:
|
|
173
|
+
if tool is None:
|
|
174
|
+
return ToolResult(ok=False, error=f"未知工具:{name}")
|
|
175
|
+
# 权限判断
|
|
176
|
+
from nexforge.core.permissions import check as perm_check
|
|
177
|
+
action = perm_check(self.config.permissions.profile, name) # type: ignore[arg-type]
|
|
178
|
+
if action == "ask" and self._approve is not None:
|
|
179
|
+
allowed = await _maybe_await(self._approve(tool, args))
|
|
180
|
+
if not allowed:
|
|
181
|
+
return ToolResult(ok=False, error="用户拒绝了该操作")
|
|
182
|
+
# 工具执行 + 超时保护
|
|
183
|
+
try:
|
|
184
|
+
return await asyncio.wait_for(
|
|
185
|
+
asyncio.to_thread(self.registry.execute, name, args),
|
|
186
|
+
timeout=self.config.tools.tool_timeout,
|
|
187
|
+
)
|
|
188
|
+
except asyncio.TimeoutError:
|
|
189
|
+
return ToolResult(ok=False, error=f"工具 {name} 执行超时({self.config.tools.tool_timeout}s)")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
# one-shot 模式:跑一轮,流式打印到 stdout,末尾把用量打到 stderr。
|
|
194
|
+
# --------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def run_oneshot(config: NexForgeConfig, prompt: str) -> int:
|
|
197
|
+
import sys
|
|
198
|
+
|
|
199
|
+
async def _run() -> int:
|
|
200
|
+
try:
|
|
201
|
+
provider = DeepSeekProvider(config)
|
|
202
|
+
except ProviderError as exc:
|
|
203
|
+
print(str(exc), file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
session = Session()
|
|
207
|
+
agent = AgentLoop(config, provider, session) # 自动放行工具(one-shot)
|
|
208
|
+
in_reasoning = False
|
|
209
|
+
had_error = False
|
|
210
|
+
|
|
211
|
+
async for ev in agent.run_turn(prompt):
|
|
212
|
+
if ev.kind is AgentEventKind.REASONING:
|
|
213
|
+
# 思考链输出到 stderr(灰色),不污染 stdout 主结果
|
|
214
|
+
if not in_reasoning:
|
|
215
|
+
print("\033[2m", end="", file=sys.stderr)
|
|
216
|
+
in_reasoning = True
|
|
217
|
+
print(ev.text, end="", flush=True, file=sys.stderr)
|
|
218
|
+
elif ev.kind is AgentEventKind.CONTENT:
|
|
219
|
+
if in_reasoning:
|
|
220
|
+
print("\033[0m", file=sys.stderr)
|
|
221
|
+
in_reasoning = False
|
|
222
|
+
print(ev.text, end="", flush=True)
|
|
223
|
+
elif ev.kind is AgentEventKind.TOOL_START:
|
|
224
|
+
print(f"\n\033[36m● {ev.tool_preview}\033[0m", file=sys.stderr)
|
|
225
|
+
elif ev.kind is AgentEventKind.TOOL_END:
|
|
226
|
+
mark = "✔" if ev.tool_ok else "✖"
|
|
227
|
+
print(f" {mark} {ev.tool_summary}", file=sys.stderr)
|
|
228
|
+
elif ev.kind is AgentEventKind.ERROR:
|
|
229
|
+
print(f"\n\033[31m{ev.text}\033[0m", file=sys.stderr)
|
|
230
|
+
had_error = True
|
|
231
|
+
elif ev.kind is AgentEventKind.TURN_DONE:
|
|
232
|
+
u = session.total_usage
|
|
233
|
+
cost = u.cost_usd(config.price(config.models.default_tier))
|
|
234
|
+
print(
|
|
235
|
+
f"\n\033[2m— tokens: {u.prompt_tokens}+{u.completion_tokens} "
|
|
236
|
+
f"· ~${cost:.4f}\033[0m",
|
|
237
|
+
file=sys.stderr,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
print() # 结尾换行
|
|
241
|
+
await provider.aclose()
|
|
242
|
+
return 1 if had_error else 0
|
|
243
|
+
|
|
244
|
+
return asyncio.run(_run())
|
nexforge/core/jobs.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""后台作业管理器 —— 跑 dev server / 长任务,不阻塞主循环。
|
|
2
|
+
|
|
3
|
+
进程级单例 JobManager 持有所有作业;工具层薄封装暴露给模型。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Job:
|
|
16
|
+
id: int
|
|
17
|
+
command: str
|
|
18
|
+
proc: subprocess.Popen
|
|
19
|
+
output: list[str] = field(default_factory=list)
|
|
20
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def running(self) -> bool:
|
|
24
|
+
return self.proc.poll() is None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def exit_code(self) -> int | None:
|
|
28
|
+
return self.proc.poll()
|
|
29
|
+
|
|
30
|
+
def append(self, line: str) -> None:
|
|
31
|
+
with self._lock:
|
|
32
|
+
self.output.append(line)
|
|
33
|
+
|
|
34
|
+
def tail(self, n: int = 80) -> str:
|
|
35
|
+
with self._lock:
|
|
36
|
+
lines = self.output[-n:] if n else list(self.output)
|
|
37
|
+
return "".join(lines)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JobManager:
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._jobs: dict[int, Job] = {}
|
|
43
|
+
self._next_id = 1
|
|
44
|
+
|
|
45
|
+
def start(self, command: str) -> Job:
|
|
46
|
+
proc = subprocess.Popen(
|
|
47
|
+
command,
|
|
48
|
+
shell=True,
|
|
49
|
+
stdin=subprocess.DEVNULL,
|
|
50
|
+
stdout=subprocess.PIPE,
|
|
51
|
+
stderr=subprocess.STDOUT,
|
|
52
|
+
text=True,
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
errors="replace",
|
|
55
|
+
bufsize=1,
|
|
56
|
+
)
|
|
57
|
+
self._jobs[job.id] = job
|
|
58
|
+
self._next_id += 1
|
|
59
|
+
|
|
60
|
+
def _pump() -> None:
|
|
61
|
+
assert proc.stdout is not None
|
|
62
|
+
for line in proc.stdout:
|
|
63
|
+
job.append(line)
|
|
64
|
+
|
|
65
|
+
threading.Thread(target=_pump, daemon=True).start()
|
|
66
|
+
return job
|
|
67
|
+
|
|
68
|
+
def get(self, job_id: int) -> Job | None:
|
|
69
|
+
return self._jobs.get(job_id)
|
|
70
|
+
|
|
71
|
+
def all(self) -> list[Job]:
|
|
72
|
+
return list(self._jobs.values())
|
|
73
|
+
|
|
74
|
+
def wait(self, job_id: int, timeout: float) -> Job | None:
|
|
75
|
+
job = self.get(job_id)
|
|
76
|
+
if job is None:
|
|
77
|
+
return None
|
|
78
|
+
deadline = time.time() + timeout
|
|
79
|
+
while job.running and time.time() < deadline:
|
|
80
|
+
time.sleep(0.1)
|
|
81
|
+
return job
|
|
82
|
+
|
|
83
|
+
def stop(self, job_id: int) -> Job | None:
|
|
84
|
+
job = self.get(job_id)
|
|
85
|
+
if job is None:
|
|
86
|
+
return None
|
|
87
|
+
if job.running:
|
|
88
|
+
job.proc.terminate()
|
|
89
|
+
try:
|
|
90
|
+
job.proc.wait(timeout=3)
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
job.proc.kill()
|
|
93
|
+
return job
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# 进程级单例
|
|
97
|
+
job_manager = JobManager()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""权限判断 —— 根据 profile 和工具名决定 allow/ask/deny。
|
|
2
|
+
|
|
3
|
+
Profile:
|
|
4
|
+
safe — 全部写操作需审批(审核模式)
|
|
5
|
+
supervised — 写文件自动,危险操作(删/Shell/git_commit)需审批(默认)
|
|
6
|
+
auto — 全部放行(全自动)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
Profile = Literal["safe", "supervised", "auto"]
|
|
14
|
+
|
|
15
|
+
# 工具分类
|
|
16
|
+
CATEGORIES: dict[str, str] = {
|
|
17
|
+
# 只读
|
|
18
|
+
"read_file": "read",
|
|
19
|
+
"list_dir": "read",
|
|
20
|
+
"search_content": "read",
|
|
21
|
+
"search_files": "read",
|
|
22
|
+
"web_fetch": "read",
|
|
23
|
+
"git_status": "read",
|
|
24
|
+
"git_diff": "read",
|
|
25
|
+
# 写文件
|
|
26
|
+
"write_file": "write_file",
|
|
27
|
+
"edit_file": "edit_file",
|
|
28
|
+
"multi_edit": "edit_file",
|
|
29
|
+
"create_dir": "manage_fs",
|
|
30
|
+
"move": "manage_fs",
|
|
31
|
+
"copy": "manage_fs",
|
|
32
|
+
# 危险操作
|
|
33
|
+
"delete": "danger",
|
|
34
|
+
"run_shell": "danger",
|
|
35
|
+
"run_background": "danger",
|
|
36
|
+
"stop_job": "danger",
|
|
37
|
+
"job_output": "read",
|
|
38
|
+
"wait_for_job": "read",
|
|
39
|
+
"list_jobs": "read",
|
|
40
|
+
"git_commit": "danger",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# 每个 profile 下各分类的权限:allow/ask/deny
|
|
44
|
+
RULES: dict[Profile, dict[str, str]] = {
|
|
45
|
+
"safe": {
|
|
46
|
+
"read": "allow",
|
|
47
|
+
"write_file": "ask",
|
|
48
|
+
"edit_file": "ask",
|
|
49
|
+
"manage_fs": "ask",
|
|
50
|
+
"danger": "ask",
|
|
51
|
+
},
|
|
52
|
+
"supervised": {
|
|
53
|
+
"read": "allow",
|
|
54
|
+
"write_file": "allow",
|
|
55
|
+
"edit_file": "allow",
|
|
56
|
+
"manage_fs": "ask",
|
|
57
|
+
"danger": "ask",
|
|
58
|
+
},
|
|
59
|
+
"auto": {
|
|
60
|
+
"read": "allow",
|
|
61
|
+
"write_file": "allow",
|
|
62
|
+
"edit_file": "allow",
|
|
63
|
+
"manage_fs": "allow",
|
|
64
|
+
"danger": "allow",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check(profile: Profile, tool_name: str) -> str:
|
|
70
|
+
"""返回 'allow'/'ask'/'deny'。"""
|
|
71
|
+
cat = CATEGORIES.get(tool_name, "danger") # 未知工具当危险
|
|
72
|
+
rule = RULES.get(profile, {})
|
|
73
|
+
return rule.get(cat, "ask")
|
nexforge/core/router.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""auto 档位的客户端路由器。
|
|
2
|
+
|
|
3
|
+
官方无 auto 模型,这里在客户端决定 (model 档位, thinking, effort):
|
|
4
|
+
- default_tier 明确为 flash/pro 时:直接采用,不路由。
|
|
5
|
+
- default_tier=auto 时:默认 flash 起步;命中升级信号 → pro + 思考 + 更高 effort。
|
|
6
|
+
升级信号:关键词、上下文体量、同一任务工具连续失败次数。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from nexforge.config import Effort, NexForgeConfig, Tier
|
|
14
|
+
from nexforge.core.session import Session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RouteDecision:
|
|
19
|
+
tier: Tier
|
|
20
|
+
thinking: bool
|
|
21
|
+
effort: Effort
|
|
22
|
+
reason: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Router:
|
|
26
|
+
def __init__(self, config: NexForgeConfig):
|
|
27
|
+
self.config = config
|
|
28
|
+
|
|
29
|
+
def choose(
|
|
30
|
+
self, session: Session, user_text: str, tool_failures: int = 0
|
|
31
|
+
) -> RouteDecision:
|
|
32
|
+
cfg = self.config
|
|
33
|
+
# 非 auto:尊重用户显式选择
|
|
34
|
+
if cfg.models.default_tier != "auto":
|
|
35
|
+
return RouteDecision(
|
|
36
|
+
tier=cfg.models.default_tier,
|
|
37
|
+
thinking=cfg.models.thinking,
|
|
38
|
+
effort=cfg.models.reasoning_effort,
|
|
39
|
+
reason="固定档位",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
r = cfg.router
|
|
43
|
+
text_low = user_text.lower()
|
|
44
|
+
hit_kw = next((k for k in r.escalate_keywords if k.lower() in text_low), None)
|
|
45
|
+
big_ctx = session.approx_context_tokens() >= r.escalate_context_tokens
|
|
46
|
+
many_fail = tool_failures >= r.escalate_on_tool_failures
|
|
47
|
+
|
|
48
|
+
if hit_kw or big_ctx or many_fail:
|
|
49
|
+
why = (
|
|
50
|
+
f"关键词'{hit_kw}'" if hit_kw
|
|
51
|
+
else "上下文过大" if big_ctx
|
|
52
|
+
else f"工具失败×{tool_failures}"
|
|
53
|
+
)
|
|
54
|
+
return RouteDecision(
|
|
55
|
+
tier=r.escalate_to, thinking=True, effort="max",
|
|
56
|
+
reason=f"升级→{r.escalate_to}({why})",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return RouteDecision(
|
|
60
|
+
tier=r.entry, thinking=cfg.models.thinking,
|
|
61
|
+
effort=cfg.models.reasoning_effort, reason=f"默认{r.entry}",
|
|
62
|
+
)
|
nexforge/core/session.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""会话状态:消息列表 + 累计用量。
|
|
2
|
+
|
|
3
|
+
关键规则(DeepSeek):发生工具调用的回合,assistant 的 reasoning_content
|
|
4
|
+
必须回传给 API,否则 400;无工具调用的最终回合无需回传。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from nexforge.core.types import AssistantMessage, Usage
|
|
12
|
+
|
|
13
|
+
DEFAULT_SYSTEM = (
|
|
14
|
+
"你是 NexForgeCLI,一个运行在终端里的智能体助手,由 DeepSeek V4 驱动。\n"
|
|
15
|
+
"你可以调用工具读写文件、执行命令来完成用户的编码任务。\n"
|
|
16
|
+
"回答用简洁的中文;修改文件用 edit_file 工具;需要执行命令用 run_shell。\n"
|
|
17
|
+
"完成任务后用一两句话总结你做了什么。\n"
|
|
18
|
+
"不要用 emoji(📦🌤等),用纯文本或 Markdown 列表/表格表示信息。\n"
|
|
19
|
+
"执行 Shell 命令前先用 run_shell 探测操作系统(Windows用cmd命令,Linux/macOS用bash命令)。"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Session:
|
|
25
|
+
system_prompt: str = DEFAULT_SYSTEM
|
|
26
|
+
messages: list[dict] = field(default_factory=list)
|
|
27
|
+
total_usage: Usage = field(default_factory=Usage)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if not self.messages:
|
|
31
|
+
self.messages.append({"role": "system", "content": self.system_prompt})
|
|
32
|
+
|
|
33
|
+
def add_user(self, text: str) -> None:
|
|
34
|
+
self.messages.append({"role": "user", "content": text})
|
|
35
|
+
|
|
36
|
+
def add_assistant(self, msg: AssistantMessage) -> None:
|
|
37
|
+
# 始终回传 reasoning_content(DeepSeek 多轮思考对话要求)
|
|
38
|
+
include_reasoning = bool(msg.reasoning_content)
|
|
39
|
+
self.messages.append(msg.to_message_dict(include_reasoning=include_reasoning))
|
|
40
|
+
if msg.usage:
|
|
41
|
+
self.total_usage = self.total_usage + msg.usage
|
|
42
|
+
|
|
43
|
+
def add_tool_result(self, tool_call_id: str, content: str) -> None:
|
|
44
|
+
self.messages.append(
|
|
45
|
+
{"role": "tool", "tool_call_id": tool_call_id, "content": content}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def inject_skill_index(self, idx: str) -> None:
|
|
49
|
+
"""在 system 消息后插入技能索引。"""
|
|
50
|
+
if idx and self.messages:
|
|
51
|
+
self.messages.insert(1, {"role": "system", "content": idx})
|
|
52
|
+
|
|
53
|
+
def approx_context_tokens(self) -> int:
|
|
54
|
+
chars = sum(len(str(m.get("content") or "")) for m in self.messages)
|
|
55
|
+
return chars // 4
|
nexforge/core/types.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""核心数据类型:消息、工具调用、用量、流式事件。
|
|
2
|
+
|
|
3
|
+
设计成 provider 与 agent/ui 之间的稳定契约,避免各处直接依赖 openai 的返回类型。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Usage:
|
|
15
|
+
"""一次请求的 token 用量(含 DeepSeek 缓存命中拆分)。"""
|
|
16
|
+
|
|
17
|
+
prompt_tokens: int = 0
|
|
18
|
+
completion_tokens: int = 0
|
|
19
|
+
prompt_cache_hit_tokens: int = 0
|
|
20
|
+
prompt_cache_miss_tokens: int = 0
|
|
21
|
+
|
|
22
|
+
def normalized(self) -> "Usage":
|
|
23
|
+
"""若没有 hit/miss 拆分,则把全部输入按未命中计,保证费用不低估。"""
|
|
24
|
+
if self.prompt_cache_hit_tokens == 0 and self.prompt_cache_miss_tokens == 0:
|
|
25
|
+
return Usage(
|
|
26
|
+
prompt_tokens=self.prompt_tokens,
|
|
27
|
+
completion_tokens=self.completion_tokens,
|
|
28
|
+
prompt_cache_hit_tokens=0,
|
|
29
|
+
prompt_cache_miss_tokens=self.prompt_tokens,
|
|
30
|
+
)
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def cost_usd(self, price: Any) -> float:
|
|
34
|
+
"""price 为 config.PricingTier(每百万 token 单价)。"""
|
|
35
|
+
u = self.normalized()
|
|
36
|
+
return (
|
|
37
|
+
u.prompt_cache_hit_tokens * price.input_cache_hit
|
|
38
|
+
+ u.prompt_cache_miss_tokens * price.input_cache_miss
|
|
39
|
+
+ u.completion_tokens * price.output
|
|
40
|
+
) / 1_000_000
|
|
41
|
+
|
|
42
|
+
def __add__(self, other: "Usage") -> "Usage":
|
|
43
|
+
return Usage(
|
|
44
|
+
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
|
45
|
+
completion_tokens=self.completion_tokens + other.completion_tokens,
|
|
46
|
+
prompt_cache_hit_tokens=self.prompt_cache_hit_tokens
|
|
47
|
+
+ other.prompt_cache_hit_tokens,
|
|
48
|
+
prompt_cache_miss_tokens=self.prompt_cache_miss_tokens
|
|
49
|
+
+ other.prompt_cache_miss_tokens,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ToolCall:
|
|
55
|
+
"""模型请求的一次工具调用(OpenAI 格式)。arguments 为原始 JSON 串。"""
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
name: str
|
|
59
|
+
arguments: str = ""
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict:
|
|
62
|
+
return {
|
|
63
|
+
"id": self.id,
|
|
64
|
+
"type": "function",
|
|
65
|
+
"function": {"name": self.name, "arguments": self.arguments},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AssistantMessage:
|
|
71
|
+
"""一轮助手输出:正文 + 思考链 + 工具调用。"""
|
|
72
|
+
|
|
73
|
+
content: str = ""
|
|
74
|
+
reasoning_content: str = ""
|
|
75
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
76
|
+
usage: Usage | None = None
|
|
77
|
+
model: str = ""
|
|
78
|
+
tier: str = ""
|
|
79
|
+
|
|
80
|
+
def to_message_dict(self, *, include_reasoning: bool) -> dict:
|
|
81
|
+
"""转成可回灌 API 的 assistant 消息。
|
|
82
|
+
|
|
83
|
+
include_reasoning:发生工具调用的回合必须为 True(否则 DeepSeek 返回 400)。
|
|
84
|
+
"""
|
|
85
|
+
msg: dict = {"role": "assistant", "content": self.content or ""}
|
|
86
|
+
if self.tool_calls:
|
|
87
|
+
msg["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
|
|
88
|
+
if include_reasoning and self.reasoning_content:
|
|
89
|
+
msg["reasoning_content"] = self.reasoning_content
|
|
90
|
+
return msg
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ChunkKind(str, Enum):
|
|
94
|
+
REASONING = "reasoning" # 思考链增量
|
|
95
|
+
CONTENT = "content" # 正文增量
|
|
96
|
+
DONE = "done" # 结束,携带完整 AssistantMessage
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class StreamChunk:
|
|
101
|
+
kind: ChunkKind
|
|
102
|
+
text: str = ""
|
|
103
|
+
message: AssistantMessage | None = None # 仅 DONE 携带
|