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.
Files changed (74) hide show
  1. nexforge/__init__.py +7 -0
  2. nexforge/__main__.py +6 -0
  3. nexforge/app.py +38 -0
  4. nexforge/assets/icons/agent.svg +13 -0
  5. nexforge/assets/icons/cost.svg +5 -0
  6. nexforge/assets/icons/edit.svg +4 -0
  7. nexforge/assets/icons/err.svg +5 -0
  8. nexforge/assets/icons/file.svg +5 -0
  9. nexforge/assets/icons/model.svg +7 -0
  10. nexforge/assets/icons/ok.svg +4 -0
  11. nexforge/assets/icons/read.svg +8 -0
  12. nexforge/assets/icons/running.svg +5 -0
  13. nexforge/assets/icons/search.svg +5 -0
  14. nexforge/assets/icons/shell.svg +5 -0
  15. nexforge/assets/icons/thinking.svg +5 -0
  16. nexforge/assets/icons/web.svg +6 -0
  17. nexforge/assets/icons/write.svg +4 -0
  18. nexforge/cli.py +159 -0
  19. nexforge/config.py +192 -0
  20. nexforge/core/__init__.py +1 -0
  21. nexforge/core/agent.py +244 -0
  22. nexforge/core/jobs.py +97 -0
  23. nexforge/core/permissions.py +73 -0
  24. nexforge/core/router.py +62 -0
  25. nexforge/core/session.py +55 -0
  26. nexforge/core/types.py +103 -0
  27. nexforge/icons.py +173 -0
  28. nexforge/mcp/__init__.py +168 -0
  29. nexforge/provider/__init__.py +1 -0
  30. nexforge/provider/balance.py +37 -0
  31. nexforge/provider/deepseek.py +204 -0
  32. nexforge/skills/loader.py +102 -0
  33. nexforge/store/__init__.py +1 -0
  34. nexforge/store/plan_store.py +112 -0
  35. nexforge/store/session_store.py +206 -0
  36. nexforge/theme.tcss +128 -0
  37. nexforge/tools/__init__.py +6 -0
  38. nexforge/tools/_paths.py +22 -0
  39. nexforge/tools/background.py +111 -0
  40. nexforge/tools/base.py +56 -0
  41. nexforge/tools/edit_file.py +73 -0
  42. nexforge/tools/fs_ops.py +125 -0
  43. nexforge/tools/git.py +89 -0
  44. nexforge/tools/list_dir.py +49 -0
  45. nexforge/tools/multi_edit.py +84 -0
  46. nexforge/tools/read_file.py +51 -0
  47. nexforge/tools/registry.py +94 -0
  48. nexforge/tools/search_content.py +90 -0
  49. nexforge/tools/search_files.py +48 -0
  50. nexforge/tools/shell.py +60 -0
  51. nexforge/tools/web.py +40 -0
  52. nexforge/tools/write_file.py +40 -0
  53. nexforge/ui/__init__.py +1 -0
  54. nexforge/ui/screens/__init__.py +1 -0
  55. nexforge/ui/screens/chat.py +825 -0
  56. nexforge/ui/widgets/__init__.py +1 -0
  57. nexforge/ui/widgets/apikey_modal.py +65 -0
  58. nexforge/ui/widgets/banner.py +18 -0
  59. nexforge/ui/widgets/confirm_modal.py +58 -0
  60. nexforge/ui/widgets/diff_modal.py +67 -0
  61. nexforge/ui/widgets/messages.py +132 -0
  62. nexforge/ui/widgets/mode_warning.py +57 -0
  63. nexforge/ui/widgets/multiline_modal.py +59 -0
  64. nexforge/ui/widgets/multiline_prompt.py +25 -0
  65. nexforge/ui/widgets/plan_sidebar.py +89 -0
  66. nexforge/ui/widgets/sidebar.py +99 -0
  67. nexforge/ui/widgets/statusbar.py +70 -0
  68. nexforge/web/__init__.py +0 -0
  69. nexforge/web/server.py +177 -0
  70. nexforge/web/static/index.html +123 -0
  71. nexforge_cli-0.1.0.dist-info/METADATA +168 -0
  72. nexforge_cli-0.1.0.dist-info/RECORD +74 -0
  73. nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
  74. 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")
@@ -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
+ )
@@ -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 携带