feat(v3): T29 收尾 + T31 dsh 功能对齐
T29 流式收尾:
- _stream_partial 每次流式调用前复位(防上次成功置位导致本次失败误抛不回退)
T31 dsh(deepseek-harness)功能对齐:
- OpenAICompatChat 重试退避:传输错误/408/429/5xx 指数退避(max_retries=2),4xx 不重试
- web_fetch 工具:公网 http/https 抓取,SSRF 防护(DNS 后拒绝私网/环回/链路本地/NAT64,
512KB/15s/12k 上限,二进制嗅探拒绝),agent.allow_net 开关(默认开)
- 原子写入:write_file/edit_file 临时文件 + os.replace(Windows EPERM 退避)
- 慢工具线程卸载:run_command/web_fetch/search_files 独立线程 + asyncio.sleep 轮询
- search_files:os.walk 修剪依赖目录(替代 rglob 全量物化),不跟随符号链接
- run_command:危险命令黑名单独立拦截 + 显式 COMSPEC/sh 解释器执行
- 重复调用提醒:同工具同参数第 3 次起回喂系统提示 + repeat_warning 事件
- 会话重命名:PATCH /agent/sessions/{sid} + 前端 ✎
- 前端:SettingsView 适配密钥打码(留空保留),SPA 重新构建
- 新增 tests/test_agent_features.py(9 项);全量 318 测试通过
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-C4nTxB0s.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1)`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2)`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,`入`),d(`th`,null,`出`),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default};
|
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-kbuKhaUa.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1)`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2)`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,`入`),d(`th`,null,`出`),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-C4nTxB0s.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query:`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response:`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
|
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-kbuKhaUa.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query:`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response:`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>端云协同 LLM 协作系统</title>
|
<title>端云协同 LLM 协作系统</title>
|
||||||
<script type="module" crossorigin src="/static/assets/index-C4nTxB0s.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-kbuKhaUa.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-rGGK-sk_.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-BYO22xUl.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+48
-5
@@ -53,6 +53,45 @@ REPEAT_CALL_WARN_AT = 3
|
|||||||
# 默认循环上限
|
# 默认循环上限
|
||||||
DEFAULT_MAX_ROUNDS = 8
|
DEFAULT_MAX_ROUNDS = 8
|
||||||
|
|
||||||
|
# 可能长时间运行的工具(命令执行 / 网络抓取 / 大范围搜索)放独立线程执行,
|
||||||
|
# 不阻塞事件循环;完成等待用 asyncio.sleep 轮询(补丁运行时的 TestClient
|
||||||
|
# 每请求独立事件循环,不推进 run_in_executor 桥接;轮询在生产/测试两端都可靠)。
|
||||||
|
SLOW_TOOLS = {"run_command", "web_fetch", "search_files"}
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_tool_thread(fn, *args):
|
||||||
|
"""起守护线程执行 fn(*args),返回 (结果盒子, 线程);轮询线程存活后取盒内值。"""
|
||||||
|
import threading
|
||||||
|
box: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _runner():
|
||||||
|
try:
|
||||||
|
box["result"] = fn(*args)
|
||||||
|
except BaseException as exc: # 线程内异常回传给调用方
|
||||||
|
box["error"] = exc
|
||||||
|
|
||||||
|
t = threading.Thread(target=_runner, daemon=True, name="agenttool")
|
||||||
|
t.start()
|
||||||
|
return box, t
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_tool(tools, name, arguments):
|
||||||
|
"""统一工具分发(内部辅助)。"""
|
||||||
|
return tools.execute(name, arguments)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tool_async(tools: "WorkspaceTools", name: str,
|
||||||
|
arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""执行一个工具:慢工具放线程并轮询等完成,快工具直接内联执行。"""
|
||||||
|
if name not in SLOW_TOOLS:
|
||||||
|
return _dispatch_tool(tools, name, arguments)
|
||||||
|
box, t = _spawn_tool_thread(_dispatch_tool, tools, name, arguments)
|
||||||
|
while t.is_alive():
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
if "error" in box:
|
||||||
|
raise box["error"]
|
||||||
|
return box["result"]
|
||||||
|
|
||||||
# OpenAI tools 声明(chat/completions 请求的 tools 参数)
|
# OpenAI tools 声明(chat/completions 请求的 tools 参数)
|
||||||
TOOLS_SPEC: List[Dict[str, Any]] = [
|
TOOLS_SPEC: List[Dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
@@ -440,13 +479,17 @@ class WorkspaceTools:
|
|||||||
if blocked:
|
if blocked:
|
||||||
return {"ok": False, "error": f"命令被安全策略拒绝({blocked})。请换一种不具破坏性的做法。"}
|
return {"ok": False, "error": f"命令被安全策略拒绝({blocked})。请换一种不具破坏性的做法。"}
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
argv = [os.environ.get("COMSPEC", "cmd.exe"), "/c", command]
|
# Windows:显式走 cmd /c(与 shell=True 内部同构——整条命令包一层引号,
|
||||||
|
# 避免参数列表的 CRT 转义与 cmd 引号语义冲突);COMSPEC 取系统 shell 路径
|
||||||
|
comspec = os.environ.get("COMSPEC", "cmd.exe")
|
||||||
|
run_args: Any = f'"{comspec}" /c "{command}"'
|
||||||
|
creationflags = 0x08000000 # CREATE_NO_WINDOW
|
||||||
else:
|
else:
|
||||||
argv = ["/bin/sh", "-c", command]
|
run_args = ["/bin/sh", "-c", command]
|
||||||
creationflags = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
|
creationflags = 0
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
argv, cwd=str(self.root), capture_output=True,
|
run_args, cwd=str(self.root), capture_output=True,
|
||||||
timeout=self.shell_timeout_s, creationflags=creationflags,
|
timeout=self.shell_timeout_s, creationflags=creationflags,
|
||||||
)
|
)
|
||||||
out = (proc.stdout or b"").decode("utf-8", errors="replace")
|
out = (proc.stdout or b"").decode("utf-8", errors="replace")
|
||||||
@@ -756,7 +799,7 @@ class ToolLoop:
|
|||||||
messages.append({"role": "tool", "tool_call_id": c["id"],
|
messages.append({"role": "tool", "tool_call_id": c["id"],
|
||||||
"content": preview})
|
"content": preview})
|
||||||
continue
|
continue
|
||||||
result = await asyncio.to_thread(self.tools.execute, c["name"], c["arguments"])
|
result = await run_tool_async(self.tools, c["name"], c["arguments"])
|
||||||
preview = json.dumps(result, ensure_ascii=False)
|
preview = json.dumps(result, ensure_ascii=False)
|
||||||
if len(preview) > self.result_preview_chars:
|
if len(preview) > self.result_preview_chars:
|
||||||
preview = preview[:self.result_preview_chars] + "…(截断)"
|
preview = preview[:self.result_preview_chars] + "…(截断)"
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""dsh 功能对齐测试(T31):
|
||||||
|
|
||||||
|
- 流式 _stream_partial 复位(首次成功后再次流式失败应回退非流式,而非误抛)
|
||||||
|
- LLM 调用重试退避(5xx/传输错误重试,非可重试错误不重试)
|
||||||
|
- 会话重命名(PATCH /agent/sessions/{sid})
|
||||||
|
- 重复工具调用提醒(同工具同参数第 3 次起回喂警语)
|
||||||
|
- search_files 目录修剪(node_modules 等不进入)
|
||||||
|
- 原子写入(write_file 落盘内容完整、无 .tmp 残留)
|
||||||
|
- 慢工具线程卸载(run_tool_async 快内联/慢走线程,异常回传)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
pytest.importorskip("httpx")
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import gateway.agent as ag
|
||||||
|
from gateway.agent import OpenAICompatChat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def ws(tmp_path):
|
||||||
|
from router_system.tools import WorkspaceTools
|
||||||
|
return WorkspaceTools(tmp_path / "ws")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- _stream_partial 复位 ----------------
|
||||||
|
|
||||||
|
def _ok_sse_body():
|
||||||
|
chunks = [{"choices": [{"delta": {"content": "第一段答复"}}]},
|
||||||
|
{"choices": [{"delta": {}}], "usage": {"prompt_tokens": 1,
|
||||||
|
"completion_tokens": 1}}]
|
||||||
|
lines = [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks]
|
||||||
|
lines.append("data: [DONE]")
|
||||||
|
return ("\n\n".join(lines) + "\n\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _nonstream_body(text: str) -> bytes:
|
||||||
|
return json.dumps({
|
||||||
|
"choices": [{"message": {"content": text}}],
|
||||||
|
"usage": {"prompt_tokens": 2, "completion_tokens": 2},
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_partial_flag_resets_between_calls():
|
||||||
|
"""首次流式成功(置位)后,第二次流式失败应正常回退非流式。"""
|
||||||
|
state = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
state["n"] += 1
|
||||||
|
if state["n"] == 1:
|
||||||
|
return httpx.Response(200, content=_ok_sse_body())
|
||||||
|
# 第二次:流式 500(无部分输出)-> 应回退非流式(第 3 次请求)
|
||||||
|
if state["n"] == 2:
|
||||||
|
return httpx.Response(500, content=b"boom")
|
||||||
|
return httpx.Response(200, content=_nonstream_body("回退答案"))
|
||||||
|
|
||||||
|
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
retry_delay_s=0)
|
||||||
|
r1 = asyncio.run(chat([{"role": "user", "content": "a"}], []))
|
||||||
|
assert r1["content"] == "第一段答复"
|
||||||
|
r2 = asyncio.run(chat([{"role": "user", "content": "b"}], []))
|
||||||
|
assert r2["content"] == "回退答案" # 不因上次置位而误抛
|
||||||
|
assert state["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 重试退避 ----------------
|
||||||
|
|
||||||
|
def test_retry_on_5xx_then_success():
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
return httpx.Response(502, content=b"bad gateway")
|
||||||
|
return httpx.Response(200, content=_nonstream_body("恢复"))
|
||||||
|
|
||||||
|
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
|
||||||
|
stream=False, max_retries=2, retry_delay_s=0,
|
||||||
|
transport=httpx.MockTransport(handler))
|
||||||
|
r = asyncio.run(chat([{"role": "user", "content": "x"}], []))
|
||||||
|
assert r["content"] == "恢复" and calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_exhausted_raises():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, content=b"always down")
|
||||||
|
|
||||||
|
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
|
||||||
|
stream=False, max_retries=1, retry_delay_s=0,
|
||||||
|
transport=httpx.MockTransport(handler))
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
asyncio.run(chat([{"role": "user", "content": "x"}], []))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retry_on_4xx_client_error():
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls["n"] += 1
|
||||||
|
return httpx.Response(401, content=b"unauthorized")
|
||||||
|
|
||||||
|
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
|
||||||
|
stream=False, max_retries=2, retry_delay_s=0,
|
||||||
|
transport=httpx.MockTransport(handler))
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
asyncio.run(chat([{"role": "user", "content": "x"}], []))
|
||||||
|
assert calls["n"] == 1 # 4xx 不重试
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 会话重命名 ----------------
|
||||||
|
|
||||||
|
def test_session_rename_endpoint(tmp_path):
|
||||||
|
import gateway.api as ga
|
||||||
|
ag.reset_session_store()
|
||||||
|
ag._session_store = ag.SessionStore(root=tmp_path / "sess")
|
||||||
|
client = TestClient(ga.app)
|
||||||
|
r = client.post("/agent/sessions", json={"title": "旧名", "workspace": ""})
|
||||||
|
sid = r.json()["id"]
|
||||||
|
r2 = client.patch(f"/agent/sessions/{sid}", json={"title": "新名字"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["title"] == "新名字"
|
||||||
|
assert client.get(f"/agent/sessions/{sid}").json()["title"] == "新名字"
|
||||||
|
# 空标题 400;不存在 404
|
||||||
|
assert client.patch(f"/agent/sessions/{sid}", json={"title": " "}).status_code == 400
|
||||||
|
assert client.patch("/agent/sessions/asdeadbeef99",
|
||||||
|
json={"title": "x"}).status_code == 404
|
||||||
|
ag.reset_session_store()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 重复调用提醒 ----------------
|
||||||
|
|
||||||
|
def test_repeat_call_warning(ws, tmp_path):
|
||||||
|
"""同工具同参数第 3 次调用:回喂内容带系统提示 + repeat_warning 事件。"""
|
||||||
|
from router_system.tools import ToolLoop
|
||||||
|
seen_msgs = []
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def chat(messages, tools_spec):
|
||||||
|
seen_msgs.append(list(messages))
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] <= 3:
|
||||||
|
return {"content": None,
|
||||||
|
"tool_calls": [{"id": "c" + str(calls["n"]), "name": "read_file",
|
||||||
|
"arguments": {"path": "a.txt"}}],
|
||||||
|
"usage": {}}
|
||||||
|
return {"content": "收手了", "tool_calls": [], "usage": {}}
|
||||||
|
|
||||||
|
events = []
|
||||||
|
loop = ToolLoop(ws, chat, on_event=events.append)
|
||||||
|
(tmp_path / "ws" / "a.txt").parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
(tmp_path / "ws" / "a.txt").write_text("x", encoding="utf-8")
|
||||||
|
result = asyncio.run(loop.run("反复读"))
|
||||||
|
assert result["response"] == "收手了"
|
||||||
|
# 第 3 次工具结果消息应带提醒
|
||||||
|
tool_msgs = [m for m in seen_msgs[3] if m.get("role") == "tool"]
|
||||||
|
assert any("系统提示" in m["content"] for m in tool_msgs)
|
||||||
|
assert any(e["type"] == "repeat_warning" and e["count"] == 3 for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- search_files 修剪 ----------------
|
||||||
|
|
||||||
|
def test_search_files_prunes_skip_dirs(ws, tmp_path):
|
||||||
|
root = tmp_path / "ws"
|
||||||
|
(root / "node_modules" / "pkg").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "node_modules" / "pkg" / "dep.js").write_text("NEEDLE", encoding="utf-8")
|
||||||
|
(root / "src").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "src" / "app.js").write_text("NEEDLE", encoding="utf-8")
|
||||||
|
r = ws.search_files("NEEDLE")
|
||||||
|
files = {m["file"] for m in r["matches"]}
|
||||||
|
assert files == {"src/app.js"} # node_modules 被修剪
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 原子写入 ----------------
|
||||||
|
|
||||||
|
def test_atomic_write_roundtrip(ws, tmp_path):
|
||||||
|
ws.write_file("sub/atomic.txt", "第一版")
|
||||||
|
ws.edit_file("sub/atomic.txt", "第一版", "第二版")
|
||||||
|
assert (tmp_path / "ws" / "sub" / "atomic.txt").read_text(
|
||||||
|
encoding="utf-8") == "第二版"
|
||||||
|
# 无 .tmp 残留
|
||||||
|
leftovers = [p.name for p in (tmp_path / "ws" / "sub").iterdir()
|
||||||
|
if p.name.endswith(".tmp")]
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 慢工具线程卸载 ----------------
|
||||||
|
|
||||||
|
def _boom(*_args):
|
||||||
|
raise RuntimeError("线程内炸了")
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_tool_async_fast_inline_and_thread_exception(ws):
|
||||||
|
from router_system.tools import run_tool_async
|
||||||
|
# 快工具:内联
|
||||||
|
ws.write_file("fast.txt", "v")
|
||||||
|
r = asyncio.run(run_tool_async(ws, "read_file", {"path": "fast.txt"}))
|
||||||
|
assert r["ok"] is True and r["content"] == "v"
|
||||||
|
# 异常从线程回传(execute 以属性形式提供)
|
||||||
|
class Boom:
|
||||||
|
execute = staticmethod(_boom)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(run_tool_async(Boom(), "read_file", {"path": "x"}))
|
||||||
@@ -95,6 +95,8 @@ export interface ModelSettings {
|
|||||||
model: string
|
model: string
|
||||||
base_url: string
|
base_url: string
|
||||||
api_key: string
|
api_key: string
|
||||||
|
/** 服务端打码标志:true 表示已存 key(返回值只含前 6 位,保存时留空即保留) */
|
||||||
|
api_key_set?: boolean
|
||||||
}
|
}
|
||||||
pipeline: {
|
pipeline: {
|
||||||
fast_path: boolean
|
fast_path: boolean
|
||||||
@@ -443,6 +445,14 @@ export async function deleteAgentSession(sid: string) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PATCH /agent/sessions/{sid}:重命名会话 */
|
||||||
|
export async function renameAgentSession(sid: string, title: string) {
|
||||||
|
const { data } = await http.patch<AgentSessionDetail>(`/agent/sessions/${sid}`, {
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /agent/{id}/status */
|
/** GET /agent/{id}/status */
|
||||||
export async function getAgentStatus(requestId: string) {
|
export async function getAgentStatus(requestId: string) {
|
||||||
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
|
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<span>{{ turnsOf(s) }} 轮</span>
|
<span>{{ turnsOf(s) }} 轮</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{{ fmtTime(s.updated_at) }}</span>
|
<span>{{ fmtTime(s.updated_at) }}</span>
|
||||||
|
<span class="sess-rename" title="重命名会话" @click.stop="renameSession(s.id)">✎</span>
|
||||||
<span class="sess-del" title="删除会话" @click.stop="removeSession(s.id)">✕</span>
|
<span class="sess-del" title="删除会话" @click.stop="removeSession(s.id)">✕</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +181,8 @@ import {
|
|||||||
startAgent, getAgentStatus, watchAgent, approveAgent, getConfig, updateConfig,
|
startAgent, getAgentStatus, watchAgent, approveAgent, getConfig, updateConfig,
|
||||||
getPool,
|
getPool,
|
||||||
browseFs, getAgentWorkspaces, openWorkspace,
|
browseFs, getAgentWorkspaces, openWorkspace,
|
||||||
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession, cancelAgent,
|
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession,
|
||||||
|
renameAgentSession, cancelAgent,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
|
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
|
||||||
|
|
||||||
@@ -305,6 +307,16 @@ async function removeSession(sid: string) {
|
|||||||
refreshSessions()
|
refreshSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renameSession(sid: string) {
|
||||||
|
const cur = sessions.value.find(s => s.id === sid)
|
||||||
|
const title = prompt('新的会话名称:', cur?.title || '')
|
||||||
|
if (title === null) return // 取消
|
||||||
|
const trimmed = title.trim()
|
||||||
|
if (!trimmed) { alert('名称不能为空'); return }
|
||||||
|
await renameAgentSession(sid, trimmed)
|
||||||
|
refreshSessions()
|
||||||
|
}
|
||||||
|
|
||||||
// ── 工作区 ──────────────────────────────────────────────────────────────
|
// ── 工作区 ──────────────────────────────────────────────────────────────
|
||||||
async function openPicker() {
|
async function openPicker() {
|
||||||
pickerOpen.value = true
|
pickerOpen.value = true
|
||||||
@@ -496,16 +508,19 @@ onMounted(async () => {
|
|||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.sess-del {
|
.sess-del,
|
||||||
|
.sess-rename {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--c-caption);
|
color: var(--c-caption);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer; padding: 0 3px;
|
||||||
padding: 0 3px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.sess-item:hover .sess-del { opacity: 1; }
|
.sess-rename { margin-left: 0; }
|
||||||
|
.sess-item:hover .sess-del,
|
||||||
|
.sess-item:hover .sess-rename { opacity: 1; }
|
||||||
.sess-del:hover { color: var(--c-err); background: var(--c-err-soft); }
|
.sess-del:hover { color: var(--c-err); background: var(--c-err-soft); }
|
||||||
|
.sess-rename:hover { color: var(--c-accent, #2563eb); }
|
||||||
.sess-empty { color: var(--c-caption); font-size: 12px; text-align: center; padding: 20px 0; }
|
.sess-empty { color: var(--c-caption); font-size: 12px; text-align: center; padding: 20px 0; }
|
||||||
|
|
||||||
/* ---------- 对话区 ---------- */
|
/* ---------- 对话区 ---------- */
|
||||||
|
|||||||
@@ -331,9 +331,10 @@
|
|||||||
|
|
||||||
<!-- API Key -->
|
<!-- API Key -->
|
||||||
<div class="field full-width">
|
<div class="field full-width">
|
||||||
<label>API Key(保存在本地 config/settings.json,不入代码库;优先级高于 .env)</label>
|
<label>API Key{{ form.architect.api_key_set ? '(已设置,留空保留)' : '(保存在本地 config/settings.json,不入代码库;优先级高于 .env)' }}</label>
|
||||||
<input v-model="form.architect.api_key" type="password"
|
<input v-model="form.architect.api_key" type="password"
|
||||||
placeholder="sk-xxxxxxxxxxxxxxxx" autocomplete="off" />
|
:placeholder="form.architect.api_key_set ? '已设置,留空保留原值' : 'sk-xxxxxxxxxxxxxxxx'"
|
||||||
|
autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型选择 -->
|
<!-- 模型选择 -->
|
||||||
@@ -867,6 +868,15 @@ function _safeAssign(target: any, source: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** architect 段专用填充:api_key 服务端打码返回,已设置时清空输入框(留空=保留)。 */
|
||||||
|
function _applyArchitect(arch: any) {
|
||||||
|
if (!arch) return
|
||||||
|
const { api_key, api_key_set, ...rest } = arch
|
||||||
|
_safeAssign(form.architect, rest)
|
||||||
|
form.architect.api_key_set = Boolean(api_key_set)
|
||||||
|
form.architect.api_key = '' // 不把打码值带回表单,防止保存时覆盖真值
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -880,7 +890,7 @@ async function load() {
|
|||||||
|
|
||||||
// 安全填充表单(防止 API 返回 null 导致响应式丢失)
|
// 安全填充表单(防止 API 返回 null 导致响应式丢失)
|
||||||
if (cfg.worker) _safeAssign(form.worker, cfg.worker)
|
if (cfg.worker) _safeAssign(form.worker, cfg.worker)
|
||||||
if (cfg.architect) _safeAssign(form.architect, cfg.architect)
|
if (cfg.architect) _applyArchitect(cfg.architect)
|
||||||
if (cfg.pipeline) _safeAssign(form.pipeline, cfg.pipeline)
|
if (cfg.pipeline) _safeAssign(form.pipeline, cfg.pipeline)
|
||||||
|
|
||||||
// 保存原始快照
|
// 保存原始快照
|
||||||
@@ -914,9 +924,9 @@ async function save() {
|
|||||||
// 空 api_key 不传给后端,保留服务端已有值
|
// 空 api_key 不传给后端,保留服务端已有值
|
||||||
if (!patch.architect.api_key) delete patch.architect.api_key
|
if (!patch.architect.api_key) delete patch.architect.api_key
|
||||||
const merged = await updateConfig(patch)
|
const merged = await updateConfig(patch)
|
||||||
// 用安全方式更新(保留响应式)
|
// 用安全方式更新(保留响应式);architect 段走打码适配
|
||||||
_safeAssign(form.worker, merged.worker || {})
|
_safeAssign(form.worker, merged.worker || {})
|
||||||
_safeAssign(form.architect, merged.architect || {})
|
_applyArchitect(merged.architect || {})
|
||||||
_safeAssign(form.pipeline, merged.pipeline || {})
|
_safeAssign(form.pipeline, merged.pipeline || {})
|
||||||
original.value = JSON.parse(JSON.stringify(form))
|
original.value = JSON.parse(JSON.stringify(form))
|
||||||
saveMsg.value = { ok: true, msg: '✅ 设置已保存,管线已重建' }
|
saveMsg.value = { ok: true, msg: '✅ 设置已保存,管线已重建' }
|
||||||
@@ -935,7 +945,7 @@ async function reset() {
|
|||||||
try {
|
try {
|
||||||
const merged = await resetConfig()
|
const merged = await resetConfig()
|
||||||
_safeAssign(form.worker, merged.worker || {})
|
_safeAssign(form.worker, merged.worker || {})
|
||||||
_safeAssign(form.architect, merged.architect || {})
|
_applyArchitect(merged.architect || {})
|
||||||
_safeAssign(form.pipeline, merged.pipeline || {})
|
_safeAssign(form.pipeline, merged.pipeline || {})
|
||||||
original.value = JSON.parse(JSON.stringify(form))
|
original.value = JSON.parse(JSON.stringify(form))
|
||||||
workerModels.value = []
|
workerModels.value = []
|
||||||
|
|||||||
@@ -122,3 +122,5 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
|
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
|
||||||
| T28 | 操作审批流:approval_policy(off/dangerous/all) + 挂起/裁决/超时 + /approve + 审批卡 | ✅ 完成 | T28 |
|
| T28 | 操作审批流:approval_policy(off/dangerous/all) + 挂起/裁决/超时 + /approve + 审批卡 | ✅ 完成 | T28 |
|
||||||
| T29 | token 级流式:SSE 解析/tool_calls 碎片组装/回退 + DeltaThrottle + 打字机渲染 | ✅ 完成 | T29 |
|
| T29 | token 级流式:SSE 解析/tool_calls 碎片组装/回退 + DeltaThrottle + 打字机渲染 | ✅ 完成 | T29 |
|
||||||
|
| T30 | 安全加固(Mimosa 深度扫描驱动,D11):路径 ID 白名单/artifacts 与工件名关押/下载 dest 关押+协议白名单/config 密钥打码/Host 信任围栏+回环绑定/危险命令独立拦截/CSPRNG 抽样 | ✅ 完成 | T30 |
|
||||||
|
| T31 | dsh 功能对齐(D12):LLM 重试退避/web_fetch 工具(SSRF 防护)/原子写入/慢工具线程卸载/search 目录修剪/重复调用提醒/会话重命名 | ✅ 完成 | T31 |
|
||||||
|
|||||||
@@ -136,3 +136,44 @@
|
|||||||
DeltaThrottle ≥48 字符节流落 delta 事件;前端打字机式实时渲染 + 光标。
|
DeltaThrottle ≥48 字符节流落 delta 事件;前端打字机式实时渲染 + 光标。
|
||||||
- **配套修复**:Vue 响应式丢失 bug——闭包持有 push 前原始对象导致过程事件不渲染,
|
- **配套修复**:Vue 响应式丢失 bug——闭包持有 push 前原始对象导致过程事件不渲染,
|
||||||
改取响应式代理对象。
|
改取响应式代理对象。
|
||||||
|
|
||||||
|
## 10. 增补:安全基线(D11,T30,Mimosa 深度扫描驱动)
|
||||||
|
|
||||||
|
- **决策 D11(默认最小暴露面)**:
|
||||||
|
- **路径参数 ID 白名单**:`/runs|/agent|/agent/sessions` 的路径参数只允许
|
||||||
|
`[a-zA-Z0-9_-]{1,64}`(系统生成 ID 的字符集),非法一律 404——杀灭 Windows
|
||||||
|
反斜杠穿越变体(如 `/runs/x/artifacts/..%5C..%5C..%5C.env` 直读 .env)。
|
||||||
|
- **工件双重关押**:artifacts 端点解析后必须仍在 artifacts 目录内;pipeline
|
||||||
|
`_save_artifact`/`_read_artifact` 对模型输出的工件名消毒(剥路径成分)。
|
||||||
|
- **下载关押**:`/llama/download` 的 dest 解析后必须位于 models/ 内;URL 协议
|
||||||
|
白名单 http/https 在 HF 别名转换**之前**判定(防 file:/// 被拼成 HF 地址)。
|
||||||
|
- **密钥打码**:GET /config 的 architect.api_key 只回前 6 位 + api_key_set;
|
||||||
|
PUT 空串/缺省=保留原值(对齐 D2 池条目语义,前端"已设置,留空保留")。
|
||||||
|
- **Web 信任围栏**:TrustedHostMiddleware 默认只信 localhost/127.0.0.1/[::1]
|
||||||
|
(GATEWAY_TRUSTED_HOSTS 可覆盖,"*"=放行全部),防 DNS rebinding 打到本网关;
|
||||||
|
api.py `__main__` 默认绑定 127.0.0.1(scripts/serve.py 本就如此)。
|
||||||
|
- **独立命令拦截**:run_command 危险模式黑名单(format / rd /s / rm -rf / /
|
||||||
|
shutdown / curl|sh 等)先于审批独立拦截;shell 经显式 `"%COMSPEC%" /c` /
|
||||||
|
`/bin/sh -c` 执行(语义同 shell=True,但解释器路径受控)。
|
||||||
|
- **CSPRNG**:review 抽样缺省随机源改 random.SystemRandom。
|
||||||
|
|
||||||
|
## 11. 增补:dsh 功能对齐(D12,T31)
|
||||||
|
|
||||||
|
- **决策 D12(对齐 deepseek-harness 的健壮性功能)**:
|
||||||
|
- **重试退避**:OpenAICompatChat 非流式路径(含流式失败回退)对传输错误/
|
||||||
|
408/429/5xx 指数退避重试(max_retries=2、retry_delay_s=1.0);4xx 不重试。
|
||||||
|
- **web_fetch 工具**:抓公网 http/https 文本,dsh web_fetch 同款 SSRF 防护——
|
||||||
|
DNS 解析后拒绝私网/环回/链路本地/保留/NAT64 地址;512KB/15s/12k 字符上限、
|
||||||
|
二进制嗅探拒绝;受 `agent.allow_net` 开关(默认开),归入只读工具集。
|
||||||
|
- **原子写入**:write_file/edit_file 走同目录临时文件 + os.replace(Windows
|
||||||
|
EPERM 退避一次后降级直写),防崩溃留半截文件。
|
||||||
|
- **慢工具线程卸载**:run_command/web_fetch/search_files 在独立守护线程执行
|
||||||
|
(asyncio.sleep 轮询等完成——补丁版 TestClient 的每请求独立事件循环不推进
|
||||||
|
run_in_executor 桥接,轮询在生产/测试两端都可靠),快工具保持内联。
|
||||||
|
- **search 修剪**:os.walk 修剪依赖/构建目录(替代 rglob 全量物化),不跟随
|
||||||
|
符号链接目录,限量行为不变。
|
||||||
|
- **重复调用提醒**:同工具同参数第 3 次起在回喂结果附系统提示 + repeat_warning
|
||||||
|
事件(防模型原地打转,dsh repeat-tool-reminder 同款)。
|
||||||
|
- **会话重命名**:`PATCH /agent/sessions/{sid}` + 前端会话列表 ✎ 按钮。
|
||||||
|
- **D10 收尾修复**:_stream_partial 每次流式调用前复位(防上次成功置位导致
|
||||||
|
本次失败误抛而不回退)。
|
||||||
|
|||||||
Reference in New Issue
Block a user