lingagent 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.
- agent_harness/__init__.py +300 -0
- agent_harness/__main__.py +4 -0
- agent_harness/assembly.py +159 -0
- agent_harness/compact.py +114 -0
- agent_harness/config.py +14 -0
- agent_harness/config_wizard.py +100 -0
- agent_harness/guardrail.py +391 -0
- agent_harness/hooks.py +70 -0
- agent_harness/llm.py +344 -0
- agent_harness/loop.py +169 -0
- agent_harness/messages.py +20 -0
- agent_harness/paths.py +9 -0
- agent_harness/schema.py +151 -0
- agent_harness/sensors.py +132 -0
- agent_harness/server.py +451 -0
- agent_harness/session.py +317 -0
- agent_harness/settings.py +141 -0
- agent_harness/shell.py +100 -0
- agent_harness/subagent.py +100 -0
- agent_harness/tasks.py +96 -0
- agent_harness/tools.py +388 -0
- agent_harness/trace.py +138 -0
- agent_harness/tui.py +594 -0
- agent_harness/web_dist/assets/index-3srHxkHj.css +1 -0
- agent_harness/web_dist/assets/index-Dn4Kq7qr.js +38 -0
- agent_harness/web_dist/favicon.svg +1 -0
- agent_harness/web_dist/icons.svg +24 -0
- agent_harness/web_dist/index.html +14 -0
- agent_harness/weblaunch.py +377 -0
- lingagent-0.1.0.dist-info/METADATA +374 -0
- lingagent-0.1.0.dist-info/RECORD +33 -0
- lingagent-0.1.0.dist-info/WHEEL +4 -0
- lingagent-0.1.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Agent Harness — 用代码造一台会自己干活的 agent 机器。"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import tui
|
|
7
|
+
from .assembly import (
|
|
8
|
+
POST_HOOKS,
|
|
9
|
+
PRE_HOOKS,
|
|
10
|
+
WORKSPACE,
|
|
11
|
+
build_session_tools,
|
|
12
|
+
build_system_prompt,
|
|
13
|
+
list_visible_sessions,
|
|
14
|
+
load_settings_and_llm,
|
|
15
|
+
)
|
|
16
|
+
from .guardrail import command_head
|
|
17
|
+
from .compact import estimate_tokens
|
|
18
|
+
from .shell import detect as detect_shell
|
|
19
|
+
from .session import (
|
|
20
|
+
list_sessions,
|
|
21
|
+
list_trace_files,
|
|
22
|
+
load_session,
|
|
23
|
+
load_session_model,
|
|
24
|
+
new_session_id,
|
|
25
|
+
prune_empty_sessions,
|
|
26
|
+
save_messages,
|
|
27
|
+
save_session_model,
|
|
28
|
+
trace_path,
|
|
29
|
+
)
|
|
30
|
+
from .loop import agent_loop
|
|
31
|
+
from .llm import Msg, DeepSeekLLM
|
|
32
|
+
from .trace import Tracer, days_since_viewed, load_trace, mark_viewed
|
|
33
|
+
from .weblaunch import WebRun, clear_state, ensure_web, stop_run, wait_until_idle
|
|
34
|
+
|
|
35
|
+
def choose_session(workspace: Path, default_profile: str = "") -> tuple[str, list[Msg]]:
|
|
36
|
+
"""列历史会话让用户选(分页,每页 10 条);返回 (session_id, history)。
|
|
37
|
+
|
|
38
|
+
default_profile:没记过模型的会话会显示成「xxx(默认)」——它打开时确实会用这个,
|
|
39
|
+
写一根横杠等于让人以为「不知道用什么」,而其实答案明确。
|
|
40
|
+
"""
|
|
41
|
+
if not tui.interactive():
|
|
42
|
+
return new_session_id(workspace), [] # 非终端(管道喂任务):选择没意义,直接新会话
|
|
43
|
+
sessions = list_visible_sessions()
|
|
44
|
+
if not sessions:
|
|
45
|
+
return new_session_id(workspace), [] # 首次:开新会话
|
|
46
|
+
|
|
47
|
+
per_page = 10
|
|
48
|
+
page = 1
|
|
49
|
+
while True:
|
|
50
|
+
total_pages = (len(sessions) + per_page - 1) // per_page
|
|
51
|
+
page = min(page, total_pages)
|
|
52
|
+
chunk = sessions[(page - 1) * per_page: page * per_page]
|
|
53
|
+
rows = [
|
|
54
|
+
{
|
|
55
|
+
"time": time.strftime("%m-%d %H:%M", time.localtime(s["mtime"])),
|
|
56
|
+
"model": load_session_model(workspace, s["id"])
|
|
57
|
+
or (f"{default_profile}(默认)" if default_profile else "-"),
|
|
58
|
+
"preview": (s["first_user_msg"] or "(空会话)").replace("\n", " ")[:40],
|
|
59
|
+
}
|
|
60
|
+
for s in chunk
|
|
61
|
+
]
|
|
62
|
+
tui.show_sessions(rows, page, total_pages, len(sessions))
|
|
63
|
+
try:
|
|
64
|
+
choice = input("选择> ").strip().lower()
|
|
65
|
+
except EOFError: # 终端读尽(Ctrl+Z 回车)——开新会话
|
|
66
|
+
return new_session_id(workspace), []
|
|
67
|
+
if choice in ("0", ""):
|
|
68
|
+
return new_session_id(workspace), [] # 新会话
|
|
69
|
+
if choice == "n" and page < total_pages:
|
|
70
|
+
page += 1
|
|
71
|
+
elif choice == "p" and page > 1:
|
|
72
|
+
page -= 1
|
|
73
|
+
elif choice.isdigit() and 1 <= int(choice) <= len(chunk):
|
|
74
|
+
s = chunk[int(choice) - 1]
|
|
75
|
+
return s["id"], load_session(workspace, s["id"])
|
|
76
|
+
else:
|
|
77
|
+
tui.show_note("没这个选项——按提示来", "warn")
|
|
78
|
+
|
|
79
|
+
NUDGE_DAYS = 30 # 轨迹这么久没人看,就该考虑清掉或关掉
|
|
80
|
+
|
|
81
|
+
# 审批「算放行」的返回值白名单。写成白名单而不是 `!= "deny"`:那是 fail-open——
|
|
82
|
+
# 将来 show_approval 多返回一种结局(超时 / 界面出错),谁忘了改这行,它就变成"批准"。
|
|
83
|
+
# 现在未知值一律按驳回走;驳回只会让命令不执行,误伤看得见,静默放行看不见。
|
|
84
|
+
_APPROVED = ("approve", "always", "auto")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_approved(decision: str) -> bool:
|
|
88
|
+
return decision in _APPROVED
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _trace_nudge(settings) -> None:
|
|
92
|
+
"""轨迹堆着没人看时提醒一句(只提示:删/关都由人决定)。"""
|
|
93
|
+
if not settings.trace_enabled:
|
|
94
|
+
return
|
|
95
|
+
days = days_since_viewed()
|
|
96
|
+
if days is None: # 从没看过:拿最早的轨迹文件当基准,别对新项目唠叨
|
|
97
|
+
files = list_trace_files(WORKSPACE)
|
|
98
|
+
if not files:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
days = (time.time() - min(f.stat().st_mtime for f in files)) / 86400
|
|
102
|
+
except OSError:
|
|
103
|
+
return
|
|
104
|
+
if days >= NUDGE_DAYS:
|
|
105
|
+
tui.show_note(
|
|
106
|
+
f"轨迹已 {int(days)} 天没打开过——清空间:uv run agent-harness --clean-trace 30;"
|
|
107
|
+
f"不需要就在配置里设 [trace] enabled = false",
|
|
108
|
+
"warn",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _let_web_finish(settings) -> None:
|
|
113
|
+
"""关后端之前,等网页上正在跑的那一轮跑完。
|
|
114
|
+
|
|
115
|
+
不等的话那一轮会被拦腰砍断:工具可能正改文件、trace 停在半截、人在浏览器里等一个永远不来的
|
|
116
|
+
回复。等是有上限的(`[web] stop_wait`),Ctrl+C 可以当场不等。
|
|
117
|
+
"""
|
|
118
|
+
if settings.web_stop_wait <= 0:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
def on_wait(busy: int) -> None:
|
|
122
|
+
tui.console.print(
|
|
123
|
+
f"[dim]网页上还有 {busy} 轮在跑——等它写完再关"
|
|
124
|
+
f"(最多 {settings.web_stop_wait:g} 秒,Ctrl+C 直接关)[/]"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
wait_until_idle(settings.server_port, settings.web_stop_wait, on_wait=on_wait)
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
tui.console.print("[dim]不等了,直接关[/]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> None:
|
|
134
|
+
import argparse
|
|
135
|
+
|
|
136
|
+
ap = argparse.ArgumentParser(description="Agent Harness CLI")
|
|
137
|
+
ap.add_argument("--model", help="模型 profile 名(配置 [models.*];缺省用 default)")
|
|
138
|
+
ap.add_argument(
|
|
139
|
+
"--clean-trace", type=int, metavar="DAYS",
|
|
140
|
+
help="删掉 DAYS 天前的轨迹文件(0 = 全部),腾空间;不启动会话",
|
|
141
|
+
)
|
|
142
|
+
ap.add_argument(
|
|
143
|
+
"--stop-web", action="store_true",
|
|
144
|
+
help="结束占用网页端端口(后端 + 前端 dev server)的进程;不启动会话",
|
|
145
|
+
)
|
|
146
|
+
ap.add_argument(
|
|
147
|
+
"--init", action="store_true",
|
|
148
|
+
help="首启向导:交互式生成 ~/.agent-harness/config.toml;不启动会话",
|
|
149
|
+
)
|
|
150
|
+
args = ap.parse_args()
|
|
151
|
+
|
|
152
|
+
if args.init: # 也不该要求 API key——它就是来写 key 的
|
|
153
|
+
from .config_wizard import run_init
|
|
154
|
+
|
|
155
|
+
raise SystemExit(run_init())
|
|
156
|
+
|
|
157
|
+
if args.stop_web: # 也不该要求 API key
|
|
158
|
+
from .settings import load_settings
|
|
159
|
+
from .weblaunch import FRONTEND_PORT, stop_web # 局部导入:这两个只在 --stop-web 时才需要
|
|
160
|
+
|
|
161
|
+
for line in stop_web([load_settings().server_port, FRONTEND_PORT]):
|
|
162
|
+
print(line)
|
|
163
|
+
clear_state() # 记录里那些 pid 已经被上面收掉了,别留给下次启动去"认领"
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if args.clean_trace is not None: # 清理不该要求 API key
|
|
167
|
+
from .session import SESSION_ROOT
|
|
168
|
+
from .trace import clean_traces
|
|
169
|
+
|
|
170
|
+
n, freed = clean_traces(SESSION_ROOT, older_than_days=args.clean_trace)
|
|
171
|
+
print(f"清掉 {n} 个轨迹文件,释放 {freed / 1048576:.1f} MB")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
base_prompt = build_system_prompt()
|
|
175
|
+
llm, settings = load_settings_and_llm( # 可切换门面(流式)
|
|
176
|
+
base_prompt, args.model, on_notice=tui.show_notice,
|
|
177
|
+
)
|
|
178
|
+
# 本会话「不再问」的程序名(只在内存里,重启即忘——安全边界清楚)
|
|
179
|
+
session_allowed: set[str] = set()
|
|
180
|
+
|
|
181
|
+
def approver(cmd: str, reason: str) -> bool:
|
|
182
|
+
head = command_head(cmd)
|
|
183
|
+
if head and head in session_allowed:
|
|
184
|
+
tui.show_note(f"本会话已允许 {head},直接执行") # 留痕:不是静默放行
|
|
185
|
+
return True
|
|
186
|
+
decision = tui.show_approval(cmd, reason, timeout=settings.approval_timeout)
|
|
187
|
+
if decision == "auto":
|
|
188
|
+
# 自动批准也要留账:屏幕上那行会滚走,trace 才是事后能查的地方
|
|
189
|
+
tracer.emit("auto", command=cmd, reason=reason)
|
|
190
|
+
if decision == "always" and head:
|
|
191
|
+
session_allowed.add(head)
|
|
192
|
+
tui.show_note(f"本会话不再询问 {head}", "info")
|
|
193
|
+
return _is_approved(decision)
|
|
194
|
+
|
|
195
|
+
web = WebRun()
|
|
196
|
+
if settings.web_autostart and tui.interactive(): # 管道/脚本模式不拉网页端
|
|
197
|
+
web = ensure_web(settings.server_port, WORKSPACE)
|
|
198
|
+
|
|
199
|
+
prune_empty_sessions(WORKSPACE) # 打扫:清掉「开了没聊」的空占位(不阻塞——顺手的事)
|
|
200
|
+
session_id, history = choose_session(WORKSPACE, settings.default_model)
|
|
201
|
+
|
|
202
|
+
# 底部显示上下文占比:分母是压缩阈值(同一个数,模型撞上去就会被压),
|
|
203
|
+
# 分子是「系统提示 + 历史」的字符当量。history 每轮会被替换成新列表,
|
|
204
|
+
# 闭包读的是那个变量本身,所以下一轮的数字自动是新的。
|
|
205
|
+
tui.set_context_probe(
|
|
206
|
+
lambda: (estimate_tokens(history) + len(base_prompt), settings.compact_threshold)
|
|
207
|
+
)
|
|
208
|
+
# 会话级模型(用户定):打开哪个会话用它上次用的模型;--model 显式指定优先
|
|
209
|
+
saved = load_session_model(WORKSPACE, session_id)
|
|
210
|
+
if saved and saved not in settings.models:
|
|
211
|
+
tui.show_note(f"会话原用模型 {saved} 已不在配置里,回退默认", "warn")
|
|
212
|
+
target = args.model or (saved if saved in settings.models else None)
|
|
213
|
+
if target and target != llm.current_profile:
|
|
214
|
+
llm.switch(target)
|
|
215
|
+
if args.model: # 显式指定 → 落账(这个会话从此认它)
|
|
216
|
+
save_session_model(WORKSPACE, session_id, llm.current_profile)
|
|
217
|
+
# S8:轨迹与 session 同目录同 id;关掉 [trace] enabled 就给空对象(不写盘)
|
|
218
|
+
tracer = Tracer(trace_path(WORKSPACE, session_id)) if settings.trace_enabled else Tracer()
|
|
219
|
+
|
|
220
|
+
def _notice(text: str, **fields) -> None:
|
|
221
|
+
"""压缩这类提示:给人看一眼(流里一行),同时记进 trace(翻账时能看到)。"""
|
|
222
|
+
tui.show_notice(text)
|
|
223
|
+
tracer.emit("compact", text=text, **fields)
|
|
224
|
+
|
|
225
|
+
llm.set_notice(_notice)
|
|
226
|
+
session_tools = build_session_tools( # S10:本会话工具表(带 spawn_subagent)
|
|
227
|
+
llm, base_prompt, approver=approver, tracer=tracer,
|
|
228
|
+
max_steps=settings.max_steps, on_hook=tui.show_hook,
|
|
229
|
+
allow_commands=settings.guardrail_allow,
|
|
230
|
+
)
|
|
231
|
+
tui.show_banner(session_id, len(history), llm.current, detect_shell().name)
|
|
232
|
+
if history:
|
|
233
|
+
tui.show_history(history) # 恢复的会话:把历史渲染出来
|
|
234
|
+
tui.show_web_hint(web.started, web.warning, web.reaped, web.ui_port) # 放历史之后,不用往上翻
|
|
235
|
+
_trace_nudge(settings)
|
|
236
|
+
try:
|
|
237
|
+
while True:
|
|
238
|
+
try:
|
|
239
|
+
goal = tui.ask().strip()
|
|
240
|
+
except EOFError:
|
|
241
|
+
break # 输入读尽(管道喂完 / Ctrl+Z 回车)——收工退出,不是错误
|
|
242
|
+
if goal in ("exit", "quit"):
|
|
243
|
+
break
|
|
244
|
+
if goal == "/trace" or goal.startswith("/trace "): # S8:轨迹时间线(只读投影)
|
|
245
|
+
recs = load_trace(trace_path(WORKSPACE, session_id))
|
|
246
|
+
mark_viewed() # 体温计:这个功能还有人在用吗
|
|
247
|
+
arg = goal[len("/trace"):].strip()
|
|
248
|
+
if not arg:
|
|
249
|
+
tui.show_trace(recs, session_id) # 总览
|
|
250
|
+
elif arg.isdigit() and 1 <= int(arg) <= len(recs):
|
|
251
|
+
tui.show_trace_event(recs[int(arg) - 1], int(arg)) # 钻取单条全文
|
|
252
|
+
else:
|
|
253
|
+
tui.show_note(f"没有 #{arg} 这条事件(当前共 {len(recs)} 条)", "warn")
|
|
254
|
+
continue
|
|
255
|
+
if goal == "/model": # 运行时切换模型(会话级——落盘,退出不丢)
|
|
256
|
+
tui.show_models(llm.profiles, llm.current_profile)
|
|
257
|
+
pick = tui.ask().strip()
|
|
258
|
+
if pick.isdigit() and 1 <= int(pick) <= len(llm.profiles):
|
|
259
|
+
name = llm.profiles[int(pick) - 1]
|
|
260
|
+
tui.show_note(f"已切换到 {llm.switch(name)}")
|
|
261
|
+
save_session_model(WORKSPACE, session_id, name) # 记在会话上,下次打开还认
|
|
262
|
+
elif pick: # 空输入 = 取消(面板上写了);打了别的东西 = 说清楚,别静默吞
|
|
263
|
+
tui.show_note("没这个序号——按提示来", "warn")
|
|
264
|
+
continue
|
|
265
|
+
if not goal:
|
|
266
|
+
continue
|
|
267
|
+
tui.show_user(goal)
|
|
268
|
+
tui.begin_stream()
|
|
269
|
+
old_len = len(history)
|
|
270
|
+
try:
|
|
271
|
+
reply, history = agent_loop(
|
|
272
|
+
goal, llm, session_tools, base_prompt,
|
|
273
|
+
approver=approver, history=history,
|
|
274
|
+
on_text=tui.stream_chunk, on_tool=tui.show_tool,
|
|
275
|
+
on_reasoning=tui.reasoning_chunk,
|
|
276
|
+
tracer=tracer,
|
|
277
|
+
pre_hooks=PRE_HOOKS, post_hooks=POST_HOOKS,
|
|
278
|
+
on_hook=tui.show_hook,
|
|
279
|
+
max_steps=settings.max_steps,
|
|
280
|
+
allow_commands=settings.guardrail_allow,
|
|
281
|
+
)
|
|
282
|
+
except KeyboardInterrupt: # Ctrl+C:结束本轮,回到输入行(不是退出程序)
|
|
283
|
+
tui.end_stream()
|
|
284
|
+
tui.show_note("已中断本轮(这一轮的历史未落盘)", "warn")
|
|
285
|
+
continue
|
|
286
|
+
except Exception as e: # 网络断了 / 模型报错 / 参数解析炸了——报一句,保住会话
|
|
287
|
+
# history 是被 agent_loop 换成新列表的(history + [...]),失败时原列表没被改写
|
|
288
|
+
tui.end_stream()
|
|
289
|
+
tui.show_note(f"本轮失败:{type(e).__name__}: {e}", "error")
|
|
290
|
+
continue
|
|
291
|
+
tui.end_stream()
|
|
292
|
+
save_messages(WORKSPACE, session_id, history[old_len:])
|
|
293
|
+
finally:
|
|
294
|
+
# 关 TUI 就连带收掉网页端:留着它们会变成孤儿,下次启动按端口判断会复用旧代码
|
|
295
|
+
# (2026-10-24 就是这么卡住的:新按钮点了没反应,8000 上还是上个世纪的进程)。
|
|
296
|
+
# 只收自己拉起的——端口上原有的可能是另一个 TUI 或用户自己在用,不动。
|
|
297
|
+
if web.procs and settings.web_stop_on_exit:
|
|
298
|
+
_let_web_finish(settings) # 网页上还有一轮就先等它跑完,别拦腰砍断
|
|
299
|
+
tui.console.print(f"[dim]网页端已关闭:{'、'.join(stop_run(web))}[/]")
|
|
300
|
+
clear_state()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""装配层:公共装配(system prompt / 工作目录 / 工具注册表)——CLI 与 Web 服务共用。"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .config import load_rules
|
|
6
|
+
from .guardrail import safe_tool
|
|
7
|
+
from .hooks import forbid_env_writes
|
|
8
|
+
from .sensors import run_tests
|
|
9
|
+
from .subagent import make_spawn_tool
|
|
10
|
+
from .tools import edit_file, read_file, run_shell, task_output, task_stop, write_file
|
|
11
|
+
|
|
12
|
+
# 极简取向:只写「模型猜不出来的事实」和「必须遵守的几条」。
|
|
13
|
+
# 不写动机、不写解释、不写"你应该尽量"——每句都要能改变一次具体动作,否则删。
|
|
14
|
+
# 工具细节交给 schema(模型看的是那份),项目规矩交给 AGENTS.md(拼在下面)。
|
|
15
|
+
SYSTEM_PROMPT = (
|
|
16
|
+
"你是 agent-harness,一个 coding agent,工作目录是项目根。规则只看下方 AGENTS.md (若没有该文件则忽略)\n\n"
|
|
17
|
+
"shell 是 bash(Windows 上 Git Bash):用 ls / cat / rm,没有 dir / type / del;"
|
|
18
|
+
"路径写相对路径;同名命令先 `command -v` 确认,拿不准就用最保守的写法。\n\n"
|
|
19
|
+
"- 读用 read_file;改用 edit_file;整篇重写才用 write_file(覆盖全文)。\n"
|
|
20
|
+
"- 改完必须 run_tests 跑到绿——没验证不算完成。\n"
|
|
21
|
+
"- 其他命令用 run_shell;会改文件、执行程序或判不出的会先问你。\n"
|
|
22
|
+
"- 不会自己结束的命令(dev server / watch)加 background=true,用完 task_stop。\n"
|
|
23
|
+
"- 不出项目目录。\n\n"
|
|
24
|
+
"做完直接给结论,不再调工具。"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
WORKSPACE = Path.cwd() # 工作目录 = 项目根
|
|
28
|
+
|
|
29
|
+
# 策略名单(S9):加策略只往这里塞函数——loop 一个字不动
|
|
30
|
+
PRE_HOOKS = [forbid_env_writes]
|
|
31
|
+
POST_HOOKS: list = [] # 后钩子(观察类)——v1 空名单
|
|
32
|
+
|
|
33
|
+
tools = {
|
|
34
|
+
"read_file": safe_tool(read_file, WORKSPACE),
|
|
35
|
+
"write_file": safe_tool(write_file, WORKSPACE),
|
|
36
|
+
"edit_file": safe_tool(edit_file, WORKSPACE),
|
|
37
|
+
"run_shell": run_shell, # shell 不包——命令危险面归 H 护栏管
|
|
38
|
+
"task_output": task_output, # 后台任务:看输出
|
|
39
|
+
"task_stop": task_stop, # 后台任务:停止
|
|
40
|
+
"run_tests": run_tests, # pytest 判红绿
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_session_tools(llm, system_prompt: str, approver=None, tracer=None,
|
|
45
|
+
max_steps: int | None = None, on_hook=None,
|
|
46
|
+
should_stop=None, allow_commands=()) -> dict:
|
|
47
|
+
"""每个会话的工具表:基础工具 + spawn_subagent(闭包持有该会话的装配)。
|
|
48
|
+
|
|
49
|
+
模型 / 审批 / trace 都是会话级的——所以 spawn 不能一次性注册进模块级 tools。
|
|
50
|
+
"""
|
|
51
|
+
session_tools = dict(tools)
|
|
52
|
+
session_tools["spawn_subagent"] = make_spawn_tool(
|
|
53
|
+
llm=llm,
|
|
54
|
+
tools=tools,
|
|
55
|
+
system_prompt=system_prompt,
|
|
56
|
+
approver=approver,
|
|
57
|
+
tracer=tracer,
|
|
58
|
+
pre_hooks=PRE_HOOKS,
|
|
59
|
+
post_hooks=POST_HOOKS,
|
|
60
|
+
depth=0,
|
|
61
|
+
max_steps=max_steps,
|
|
62
|
+
on_hook=on_hook,
|
|
63
|
+
should_stop=should_stop,
|
|
64
|
+
allow_commands=allow_commands,
|
|
65
|
+
)
|
|
66
|
+
return session_tools
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_system_prompt() -> str:
|
|
70
|
+
"""基础 prompt + 规则文件(每次装配时拼最新)。"""
|
|
71
|
+
return SYSTEM_PROMPT + "\n\n" + load_rules(WORKSPACE)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_visible_sessions() -> list[dict]:
|
|
75
|
+
"""列可展示的历史会话(跳过没聊过的空会话占位)——CLI 与 Web 共用。"""
|
|
76
|
+
from .session import list_sessions
|
|
77
|
+
|
|
78
|
+
return [s for s in list_sessions(WORKSPACE) if s["first_user_msg"]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SwitchableLLM:
|
|
82
|
+
"""可运行时切换的模型门面(对主循环透明——接口与 LLM 一致)。
|
|
83
|
+
|
|
84
|
+
/model 换的是内部持有的适配器实例;history、会话全不受影响。
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, system_prompt: str, settings, name: str | None = None, on_notice=None):
|
|
88
|
+
self._sp = system_prompt
|
|
89
|
+
self._settings = settings
|
|
90
|
+
self._on_notice = on_notice
|
|
91
|
+
self._name = name or settings.default_model
|
|
92
|
+
self._inner = self._build(self._name)
|
|
93
|
+
|
|
94
|
+
def _build(self, name: str):
|
|
95
|
+
from .llm import DeepSeekLLM
|
|
96
|
+
|
|
97
|
+
p = self._settings.model(name)
|
|
98
|
+
return DeepSeekLLM(
|
|
99
|
+
self._sp,
|
|
100
|
+
model=p.name,
|
|
101
|
+
base_url=p.base_url,
|
|
102
|
+
api_key_env=p.api_key_env,
|
|
103
|
+
api_key=p.api_key,
|
|
104
|
+
temperature=p.temperature,
|
|
105
|
+
compact_threshold=self._settings.compact_threshold,
|
|
106
|
+
keep_last=self._settings.keep_last,
|
|
107
|
+
snip_keep_groups=self._settings.snip_keep_groups,
|
|
108
|
+
thinking=p.thinking,
|
|
109
|
+
reasoning_effort=p.reasoning_effort,
|
|
110
|
+
strip_think_tags=p.strip_think_tags,
|
|
111
|
+
on_notice=self._on_notice, # 换模型后新的适配器也带着这个提示口
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def set_notice(self, fn) -> None:
|
|
115
|
+
"""换提示口(新建的适配器会带着它,见 _build)。"""
|
|
116
|
+
self._on_notice = fn
|
|
117
|
+
self._inner.set_notice(fn)
|
|
118
|
+
|
|
119
|
+
def switch(self, name: str) -> str:
|
|
120
|
+
"""切到某个 profile——返回实际模型名(供界面提示)。名字未知抛 KeyError。"""
|
|
121
|
+
if name not in self._settings.models:
|
|
122
|
+
raise KeyError(name)
|
|
123
|
+
self._name = name
|
|
124
|
+
self._inner = self._build(name)
|
|
125
|
+
return self.current
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def current(self) -> str:
|
|
129
|
+
"""当前实际模型名(如 deepseek-v4-pro)。"""
|
|
130
|
+
return self._settings.model(self._name).name
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def current_profile(self) -> str:
|
|
134
|
+
"""当前 profile 名(如 flash)。"""
|
|
135
|
+
return self._name
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def profiles(self) -> list[str]:
|
|
139
|
+
return list(self._settings.models)
|
|
140
|
+
|
|
141
|
+
def __call__(self, messages, tools, on_text=None, on_reasoning=None, system_prompt=None):
|
|
142
|
+
return self._inner(
|
|
143
|
+
messages, tools, on_text=on_text, on_reasoning=on_reasoning,
|
|
144
|
+
system_prompt=system_prompt,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_settings_and_llm(system_prompt: str, model_name: str | None = None, on_notice=None):
|
|
149
|
+
"""按 ~/.agent_harness.toml 构造可切换模型门面——返回 (llm, settings)。
|
|
150
|
+
|
|
151
|
+
model_name:初始 profile(None = 配置的 default)。
|
|
152
|
+
"""
|
|
153
|
+
from .settings import load_settings
|
|
154
|
+
|
|
155
|
+
s = load_settings()
|
|
156
|
+
if model_name and model_name not in s.models:
|
|
157
|
+
# fail fast:名字打错就当场说,别静默回退到别的模型
|
|
158
|
+
raise SystemExit(f"未知模型 profile:{model_name}(可用:{', '.join(s.models)})")
|
|
159
|
+
return SwitchableLLM(system_prompt, s, model_name, on_notice=on_notice), s
|
agent_harness/compact.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""上下文压缩(Compress 策略):投影式微压缩——不改 history,只压发给 API 的副本。
|
|
2
|
+
|
|
3
|
+
设计定案(2026-09-22):白名单判定 / 保留最近 N / 占位替换(结构不动·配对不破)/
|
|
4
|
+
软估阈值触发。reactive 兜底在适配器层(后续接)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from .messages import Msg
|
|
10
|
+
|
|
11
|
+
# 大输出类工具——其 tool 结果可清理(write/edit/count 结果短,不清)
|
|
12
|
+
COMPACTABLE = {"read_file", "run_shell", "run_tests"}
|
|
13
|
+
|
|
14
|
+
KEEP_LAST = 5
|
|
15
|
+
|
|
16
|
+
# 软阈值(字符数当量,约等于 token 数)——量级占位,真撞墙时按实测调
|
|
17
|
+
DEFAULT_THRESHOLD = 70_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def estimate_tokens(messages: list[Msg]) -> int:
|
|
21
|
+
"""粗估 token 数:字符数当量(中文≈1:1,英文保守高估)。不做精确 tokenizer。"""
|
|
22
|
+
total = 0
|
|
23
|
+
for m in messages:
|
|
24
|
+
total += len(m.content or "")
|
|
25
|
+
if m.tool_calls:
|
|
26
|
+
total += len(str(m.tool_calls)) # 结构本身也占量;str 兜底 pydantic 对象
|
|
27
|
+
return total
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def microcompact(messages: list[Msg], keep_last: int = KEEP_LAST) -> list[Msg]:
|
|
31
|
+
"""投影式微压缩:最近 keep_last 条白名单工具结果保留,更早的 content 换占位。
|
|
32
|
+
|
|
33
|
+
role / tool_call_id / tool_calls 一字不动——配对不破(400 防线)。
|
|
34
|
+
返回新列表且不清的对象复用引用——原 messages 完全不受影响。
|
|
35
|
+
"""
|
|
36
|
+
# 从后往前数白名单 tool 消息,超出的下标标记为「要清」
|
|
37
|
+
targets: set[int] = set()
|
|
38
|
+
seen = 0
|
|
39
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
40
|
+
m = messages[i]
|
|
41
|
+
if m.role == "tool" and m.tool_name in COMPACTABLE:
|
|
42
|
+
seen += 1
|
|
43
|
+
if seen > keep_last:
|
|
44
|
+
targets.add(i)
|
|
45
|
+
|
|
46
|
+
if not targets:
|
|
47
|
+
return list(messages) # 无需清理——返回浅壳(调用方照常使用)
|
|
48
|
+
|
|
49
|
+
out: list[Msg] = []
|
|
50
|
+
for i, m in enumerate(messages):
|
|
51
|
+
if i in targets:
|
|
52
|
+
# dataclasses.replace 产新对象——不动原 Msg(真投影)
|
|
53
|
+
out.append(
|
|
54
|
+
replace(m, content=f"[已清理:{m.tool_name} 的旧输出,{len(m.content or '')} 字符]")
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
out.append(m)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def microcompact_if_needed(
|
|
62
|
+
messages: list[Msg], threshold: int = DEFAULT_THRESHOLD, keep_last: int = KEEP_LAST
|
|
63
|
+
) -> list[Msg]:
|
|
64
|
+
"""超阈值才压(软估)——否则原样返回。"""
|
|
65
|
+
if estimate_tokens(messages) <= threshold:
|
|
66
|
+
return messages
|
|
67
|
+
return microcompact(messages, keep_last)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def snip(messages: list[Msg], keep_groups: int = 3) -> list[Msg]:
|
|
71
|
+
"""激进档(reactive 用):保首条 user + 最近 keep_groups 组,中间整段省略。
|
|
72
|
+
|
|
73
|
+
按「组」切(assistant[tool_calls] + 紧随 tool 消息 = 原子单位)——配对不破。
|
|
74
|
+
省略标记并入首条 user 的 content——不新增消息(避免连续同 role 的 provider 风险)。
|
|
75
|
+
"""
|
|
76
|
+
# 划分组:带 tool_calls 的 assistant + 紧随的 tool 消息 = 一组;其余各自一组
|
|
77
|
+
groups: list[list[int]] = []
|
|
78
|
+
i = 0
|
|
79
|
+
while i < len(messages):
|
|
80
|
+
idxs = [i]
|
|
81
|
+
if messages[i].role == "assistant" and messages[i].tool_calls:
|
|
82
|
+
j = i + 1
|
|
83
|
+
while j < len(messages) and messages[j].role == "tool":
|
|
84
|
+
idxs.append(j)
|
|
85
|
+
j += 1
|
|
86
|
+
i = j
|
|
87
|
+
else:
|
|
88
|
+
i += 1
|
|
89
|
+
groups.append(idxs)
|
|
90
|
+
|
|
91
|
+
keep: set[int] = set(groups[0]) # 首组(含任务)
|
|
92
|
+
if keep_groups > 0:
|
|
93
|
+
for g in groups[-keep_groups:]:
|
|
94
|
+
keep.update(g)
|
|
95
|
+
|
|
96
|
+
kept = [m for idx, m in enumerate(messages) if idx in keep]
|
|
97
|
+
omitted = len(messages) - len(kept)
|
|
98
|
+
if omitted == 0:
|
|
99
|
+
return list(messages)
|
|
100
|
+
|
|
101
|
+
out: list[Msg] = []
|
|
102
|
+
marked = False
|
|
103
|
+
for m in kept:
|
|
104
|
+
if not marked and m.role == "user":
|
|
105
|
+
out.append(
|
|
106
|
+
replace(
|
|
107
|
+
m,
|
|
108
|
+
content=f"{m.content}\n\n[注:为压缩请求体积,已省略中间 {omitted} 条历史消息]",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
marked = True
|
|
112
|
+
else:
|
|
113
|
+
out.append(m)
|
|
114
|
+
return out
|
agent_harness/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""配置维:规则文件自动加载——声明式约束的机制化(不靠模型自觉读)。"""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
RULE_FILES = ("AGENTS.md",)
|
|
5
|
+
|
|
6
|
+
def load_rules(workspace: Path) -> str:
|
|
7
|
+
# 逐个读存在的规则文件 → 拼成一段文本;都没有 → 返回空串
|
|
8
|
+
parts: list[str] = [] # ① 收集容器
|
|
9
|
+
for name in RULE_FILES: # ② 逐个文件名
|
|
10
|
+
p = workspace / name # ③ Path 的 / 拼路径
|
|
11
|
+
if p.exists(): # ④ 存在才读
|
|
12
|
+
text = p.read_text(encoding="utf-8")
|
|
13
|
+
parts.append(f"# {name}\n\n{text}") # ⑤ 带来源标题
|
|
14
|
+
return "\n\n".join(parts) # ⑥ 一次 join
|