Browse Source

fix(workspace): 工作区助手必须绑定工作文件夹 — 堵"静默回退空家目录假装成功"

实锤失败模式:一条对话无 ?dir(从非「工作区对话」入口或历史会话进来)→ 前端不发
context.work_dir → 后端静默回退到 agent 空家目录(.../工作区助手)→ agent 读不到
用户的 04_paper、把 plan 写进日志区、凭会话记忆编造,却以 completed 收尾。

三道防线:
1) 后端(agents.py):新增 _require_workspace_dir,模板 ∈ {workspace.assistant}
   且 context.work_dir 无效时,/run 与 /run/stream 两端点都直接 400
   WORKSPACE_DIR_REQUIRED,不再回退空家目录。回归 test_workspace_guard(5)。
2) 前端(Chat.tsx):needsWorkDir = workspace.assistant 且无 workDir → 强制弹
   DirPickerModal、禁用输入框与发送、关闭则退回工作台,杜绝无目录死会话。
3) agent 行为(assistant.yml + DB 实例 v2→v3):加"找不到就停"铁律——引用的文件
   在工作文件夹里找不到必须如实报错/反问,禁止凭记忆脑补、伪造数据、假装读过。

tsc 0 错误、webui 重建、agentpaas 20+5 测试绿、服务重启 /health 1.3.1、实例 v3。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju 2 months ago
parent
commit
1917ac34d3

+ 5 - 0
agentexample/agentpacks/workspace.assistant/agents/assistant.yml

@@ -21,6 +21,11 @@ systemPrompt: |
     **你的一切读、写、改、运行都在这个文件夹内就地完成。**
   - 读文件:用该文件夹下的**完整绝对路径** ReadFile;先 ListFiles / SearchContent
     摸清结构再动手,不要凭空假设文件存在。
+  - **找不到就停、不要编(铁律)**:用户提到某个文件/目录(如「04_paper」「那篇论文」)
+    时,**必须先用 ListFiles / Bash `ls`/`find` 在工作文件夹里实际找到它再动手**。
+    若找不到,**立刻如实告诉用户「在当前工作文件夹 X 里没找到 Y」并询问正确位置**,
+    **绝不允许凭记忆/常识脑补文件内容、伪造数据、假装读过**。读不到真实文件就没有
+    任何产物可言——宁可空手报告,也不要编一份看似完成的东西。
   - **大文件纪律(重要,省上下文、防卡顿)**:不要无脑把大文件整篇 ReadFile 灌进
     上下文。先判断大小/结构——用 Bash 跑 `wc -l 文件`、`head -n 50 文件`、
     `grep -n 关键词 文件` 摸清概况,再用 **ReadFile 的 offset/limit 只读你真正需要

+ 31 - 0
agentpaas/src/agentpaas/api/v1/agents.py

@@ -499,6 +499,31 @@ async def rollback_agent(
 
 # ── Execution Endpoint ──
 
+# 工作区对话模式必须绑定真实工作文件夹。曾出过最坑的失败模式:用户从非
+# 「工作区对话」入口(或历史会话)进来,URL 无 ?dir → 前端不发 context.work_dir
+# → 后端静默回退到 agent 空家目录 → agent 读不到用户文件、把产物写进日志区、
+# 凭会话记忆编造,却以 completed 收尾(假装成功)。这里把它变成显式拒绝:
+# 模板为 workspace.assistant 且无有效 context.work_dir 时直接 400,提示先选文件夹。
+_WORKSPACE_CONV_TEMPLATES = {"workspace.assistant"}
+
+
+def _require_workspace_dir(agent, req):
+    """workspace 对话型 agent 必须带有效 context.work_dir,否则 400 拒绝(不回退空家目录)。"""
+    import os as _os
+    if (agent.get("agent_template") or "") not in _WORKSPACE_CONV_TEMPLATES:
+        return
+    ctx_wd = (req.context or {}).get("work_dir") if isinstance(req.context, dict) else ""
+    if not (ctx_wd and isinstance(ctx_wd, str) and _os.path.isdir(ctx_wd)):
+        api_error(
+            400, "WORKSPACE_DIR_REQUIRED",
+            "工作区助手需要先选择工作文件夹再开始对话。请从「💻工作区对话」入口选择文件夹"
+            "(URL 会带上 ?dir=…),或点击聊天页顶部的 📁 选择/切换工作文件夹。"
+            "(未绑定文件夹时不会运行,以免读不到你的文件还'假装成功'。)",
+            details={"agent_template": agent.get("agent_template") or "",
+                     "got_work_dir": ctx_wd or ""},
+        )
+
+
 @router.post("/{agent_id}/run")
 async def run_agent(
     agent_id: str,
@@ -541,6 +566,9 @@ async def run_agent(
     if not agent:
         api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
 
+    # 工作区对话型 agent 必须绑定工作文件夹,否则拒绝(不回退空家目录假装成功)。
+    _require_workspace_dir(agent, req)
+
     version_rec = db.fetchone(
         "SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
         (agent_id, agent["current_version"])
@@ -718,6 +746,9 @@ async def run_agent_stream(
     if not agent:
         api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
 
+    # 工作区对话型 agent 必须绑定工作文件夹,否则拒绝(不回退空家目录假装成功)。
+    _require_workspace_dir(agent, req)
+
     version_rec = db.fetchone(
         "SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
         (agent_id, agent["current_version"])

+ 49 - 0
tests/test_workspace_guard.py

@@ -0,0 +1,49 @@
+"""工作区对话型 agent 必须绑定工作文件夹的后端守卫测试。
+
+防的是最坑的失败模式:无 ?dir → 静默回退 agent 空家目录 → 读不到用户文件
+还以 completed 收尾(假装成功)。守卫把它变成显式 400 WORKSPACE_DIR_REQUIRED。
+"""
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+
+from agentpaas.api.v1.agents import _require_workspace_dir
+
+
+class _Req:
+    def __init__(self, ctx):
+        self.context = ctx
+
+
+def test_workspace_assistant_rejects_empty_context():
+    agent = {"agent_template": "workspace.assistant"}
+    with pytest.raises(HTTPException) as ei:
+        _require_workspace_dir(agent, _Req({}))
+    assert ei.value.status_code == 400
+    assert ei.value.detail["code"] == "WORKSPACE_DIR_REQUIRED"
+
+
+def test_workspace_assistant_rejects_nonexistent_dir():
+    agent = {"agent_template": "workspace.assistant"}
+    with pytest.raises(HTTPException):
+        _require_workspace_dir(agent, _Req({"work_dir": "/no/such/dir/zzz_nope"}))
+
+
+def test_workspace_assistant_accepts_valid_dir(tmp_path):
+    agent = {"agent_template": "workspace.assistant"}
+    # 有效目录 → 不抛
+    _require_workspace_dir(agent, _Req({"work_dir": str(tmp_path)}))
+
+
+def test_workspace_assistant_rejects_non_dict_context():
+    agent = {"agent_template": "workspace.assistant"}
+    with pytest.raises(HTTPException):
+        _require_workspace_dir(agent, _Req(None))
+
+
+def test_non_workspace_agent_unaffected():
+    # 专项智能体不受此守卫约束(它们写各自 run 工作区)
+    agent = {"agent_template": "research.top-journal-reviewer"}
+    _require_workspace_dir(agent, _Req({}))  # 不抛
+    _require_workspace_dir({"agent_template": ""}, _Req({}))  # 空模板也不抛

+ 21 - 4
webui/src/pages/Chat.tsx

@@ -1139,6 +1139,14 @@ export default function Chat() {
     enabled: !!agentId,
   })
 
+  // 工作区对话型助手(workspace.assistant)必须先绑定工作文件夹。无 ?dir / workDir
+  // 时不能对话——否则后端会拒绝(WORKSPACE_DIR_REQUIRED),且 agent 读不到用户文件
+  // 还'假装成功'。此处在前端先强制弹文件夹选择,禁用输入,关掉则退回工作台。
+  const needsWorkDir = agent?.agent_template === 'workspace.assistant' && !workDir
+  useEffect(() => {
+    if (needsWorkDir) setDirPickerOpen(true)
+  }, [needsWorkDir])
+
   // Extract maxSteps from agent config for the Lambda Trace step counter
   const agentMaxSteps = (() => {
     const cfg = (agent?.config ?? {}) as Record<string, unknown>
@@ -1316,6 +1324,8 @@ export default function Chat() {
 
   const sendMessage = useCallback(async () => {
     if (!input.trim() || streaming || !agentId) return
+    // 工作区助手未绑定文件夹:不发,改为强制弹文件夹选择。
+    if (needsWorkDir) { setDirPickerOpen(true); return }
 
     const userText = input.trim()
     // Read from refs — always current, no stale-closure issue
@@ -1679,7 +1689,7 @@ export default function Chat() {
       setStreaming(false)
       abortRef.current = null
     }
-  }, [input, streaming, agentId, apiKey, qc])
+  }, [input, streaming, agentId, apiKey, qc, needsWorkDir])
 
   // Belt-and-suspenders safety net: if the SSE stream silently drops (eg. the
   // backend was restarted), the page would otherwise leave `streaming` stuck
@@ -1900,7 +1910,11 @@ export default function Chat() {
         {dirPickerOpen && (
           <DirPickerModal
             initial={workDir}
-            onClose={() => setDirPickerOpen(false)}
+            onClose={() => {
+              setDirPickerOpen(false)
+              // 工作区助手必须选目录:不选就关 → 退回工作台,不留无目录的死会话。
+              if (needsWorkDir) navigate('/dashboard')
+            }}
             onSelect={(folder) => {
               setDirPickerOpen(false)
               if (!folder || folder === workDir) return
@@ -2054,14 +2068,16 @@ export default function Chat() {
             onChange={e => setInput(e.target.value)}
             onKeyDown={handleKeyDown}
             placeholder={
-              continueFromRunId
+              needsWorkDir
+                ? '请先选择工作文件夹再开始对话…'
+                : continueFromRunId
                 ? '继续此 run — 输入下一步指令…(Enter 发送)'
                 : isDesktop
                   ? '描述你的研究任务…(Enter 发送,Shift+Enter 换行)'
                   : '发消息… (Enter 发送,Shift+Enter 换行)'
             }
             rows={1}
-            disabled={streaming}
+            disabled={streaming || needsWorkDir}
             className={clsx(
               'flex-1 resize-none rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 disabled:bg-gray-50 max-h-40 leading-relaxed transition-colors',
               continueFromRunId
@@ -2082,6 +2098,7 @@ export default function Chat() {
               onClick={sendMessage}
               disabled={
                 !input.trim() ||
+                needsWorkDir ||
                 // Edit mode requires a chosen sub-agent.
                 (!!continueFromRunId && mode === 'edit' && !targetSubagent)
               }