Prechádzať zdrojové kódy

fix(react): 修复 ReAct 循环末步工具未执行 + claude-code resume 超时

## Bug fixes
- compiler.py: 新增 _fix_json_control_chars(),自动转义 LLM 输出 JSON 中
  未转义的换行符和双引号(WriteFile content 常见问题),raw_decode 失败时
  自动修复重试;Phase 2b 新增 nudge 机制,解析失败但检测到 "tool": 时
  注入 SYSTEM 提示要求重新格式化,而非静默 terminate
- claude_code_provider.py: _call_resume 在 first-byte stall attempt 1 时
  retry 而非立即清空 session;修复 _time.sleep() → time.sleep()
- sandbox.py: create_run_workspace() 新增 bare=True 参数,避免 workspace
  双层嵌套(workspace/workspace/run_...)

## Features
- App.tsx: 注册 /agents/:agentId/workspace 路由(原缺失导致跳转到 dashboard)
- Chat.tsx: 合并 WorkspacePanel,显示路径 + 文件数 + 单一"查看"按钮
- AgentWorkspace.tsx: workspace 文件浏览页

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
kenny67nju 4 mesiacov pred
rodič
commit
83aac3c128

+ 111 - 45
agentpaas/src/agentpaas/api/v1/agents.py

@@ -38,16 +38,22 @@ class CreateAgentRequest(BaseModel):
     config: Dict[str, Any]
     tags: list = Field(default_factory=list)
     environment: str = Field(default="production", max_length=64)
-    agent_dir: str = Field(default="", description="Agent template directory path")
+    agent_dir: str = Field(default="", description="Agent template/system directory path")
     agent_template: str = Field(default="", description="Agent template name (e.g. qaagent67wiki)")
     instance_dir: str = Field(default="", description="Instance data directory for this agent")
     kb_ids: list = Field(default_factory=list, description="Linked knowledge base IDs")
     kb_search_mode: str = Field(default="bm25", description="bm25 | pageindex | fusion")
+    work_dir: str = Field(default="", description="工作产物目录: Bash tool CWD; 最终输出文件落这里")
+    source_dir: str = Field(default="", description="数据来源目录: 只读输入数据; 注入 system prompt 前缀")
+    run_dir: str = Field(default="", description="运行日志目录: run workspace/run_{ts}/ 的父目录; 若空则用 work_dir")
 
 class UpdateAgentRequest(BaseModel):
     config: Dict[str, Any]
     changelog: str = ""
     agent_dir: str = ""
+    work_dir: str = ""
+    source_dir: str = ""
+    run_dir: str = ""
     kb_ids: Optional[list] = None
     kb_search_mode: Optional[str] = None
 
@@ -128,12 +134,14 @@ async def create_agent(
     now = now_utc()
     db.execute(
         "INSERT INTO agents (id, tenant_id, name, description, current_version, tags, environment, "
-        "agent_dir, agent_template, instance_dir, kb_ids, kb_search_mode, status, created_at, updated_at) "
-        "VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
+        "agent_dir, agent_template, instance_dir, kb_ids, kb_search_mode, "
+        "work_dir, source_dir, run_dir, status, created_at, updated_at) "
+        "VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
         (agent_id, tenant.tenant_id, req.name, req.description,
          json.dumps(req.tags), req.environment,
          agent_dir, req.agent_template, instance_dir,
-         json.dumps(req.kb_ids), req.kb_search_mode, now, now)
+         json.dumps(req.kb_ids), req.kb_search_mode,
+         req.work_dir, req.source_dir, req.run_dir, now, now)
     )
     db.execute(
         "INSERT INTO agent_versions (agent_id, version, config, config_hash, changelog, created_by, created_at) "
@@ -221,24 +229,33 @@ async def update_agent(
         "SELECT config_hash FROM agent_versions WHERE agent_id = ? ORDER BY version DESC LIMIT 1",
         (agent_id,)
     )
-    if last_version and last_version["config_hash"] == config_hash:
-        return {"message": "Config unchanged, no new version created", "version": agent["current_version"]}
+    config_changed = not (last_version and last_version["config_hash"] == config_hash)
 
-    # Validate
-    try:
-        _compile_agent(req.config)
-    except Exception as e:
-        api_error(400, "INVALID_CONFIG", str(e))
-
-    new_version = agent["current_version"] + 1
     now = now_utc()
-    db.execute(
-        "INSERT INTO agent_versions (agent_id, version, config, config_hash, changelog, created_by, created_at) "
-        "VALUES (?, ?, ?, ?, ?, ?, ?)",
-        (agent_id, new_version, config_json, config_hash, req.changelog, tenant.user_id, now)
-    )
-    update_fields = "current_version = ?, agent_dir = ?, updated_at = ?"
-    update_vals = [new_version, req.agent_dir, now]
+    new_version = agent["current_version"]
+
+    if config_changed:
+        # Validate before inserting a new version
+        try:
+            _compile_agent(req.config)
+        except Exception as e:
+            api_error(400, "INVALID_CONFIG", str(e))
+
+        new_version = agent["current_version"] + 1
+        db.execute(
+            "INSERT INTO agent_versions (agent_id, version, config, config_hash, changelog, created_by, created_at) "
+            "VALUES (?, ?, ?, ?, ?, ?, ?)",
+            (agent_id, new_version, config_json, config_hash, req.changelog, tenant.user_id, now)
+        )
+
+    # Always persist directory and metadata fields regardless of whether
+    # config changed — prior bug: early return on "config unchanged" meant
+    # work_dir / source_dir / run_dir edits were silently dropped.
+    update_fields = "agent_dir = ?, work_dir = ?, source_dir = ?, run_dir = ?, updated_at = ?"
+    update_vals   = [req.agent_dir, req.work_dir, req.source_dir, req.run_dir, now]
+    if config_changed:
+        update_fields = "current_version = ?, " + update_fields
+        update_vals   = [new_version] + update_vals
     if req.kb_ids is not None:
         update_fields += ", kb_ids = ?"
         update_vals.append(json.dumps(req.kb_ids))
@@ -249,7 +266,10 @@ async def update_agent(
     db.execute(f"UPDATE agents SET {update_fields} WHERE id = ?", tuple(update_vals))
     db.commit()
 
-    return {"agent_id": agent_id, "version": new_version, "updated_at": now}
+    if config_changed:
+        return {"agent_id": agent_id, "version": new_version, "updated_at": now}
+    return {"agent_id": agent_id, "version": new_version, "updated_at": now,
+            "message": "Config unchanged; directory/metadata updated"}
 
 
 @router.delete("/{agent_id}")
@@ -389,6 +409,9 @@ async def run_agent(
     # Resolve agent_dir: prefer instance_dir (per-instance data), fallback to agent_dir
     instance_dir = agent.get("instance_dir", "") or ""
     agent_dir = instance_dir or agent.get("agent_dir", "") or ""
+    work_dir = agent.get("work_dir", "") or ""
+    source_dir = agent.get("source_dir", "") or ""
+    run_dir = agent.get("run_dir", "") or ""
 
     # KB context injection: enrich input with top-K retrieved passages
     kb_ids = json.loads(agent.get("kb_ids") or "[]")
@@ -423,6 +446,7 @@ async def run_agent(
             lambda: _execute_agent(
                 config, enriched_input, agent_dir=agent_dir, run_id=run_id,
                 continue_workspace=continue_workspace,
+                work_dir=work_dir, source_dir=source_dir, run_dir=run_dir,
             ),
         )
         duration_ms = int((time.time() - t0) * 1000)
@@ -512,6 +536,9 @@ async def run_agent_stream(
     # Resolve agent_dir: prefer instance_dir, fallback to agent_dir
     instance_dir = agent.get("instance_dir", "") or ""
     agent_dir = instance_dir or agent.get("agent_dir", "") or ""
+    work_dir = agent.get("work_dir", "") or ""
+    source_dir = agent.get("source_dir", "") or ""
+    run_dir = agent.get("run_dir", "") or ""
 
     # KB context injection for streaming run
     kb_ids = json.loads(agent.get("kb_ids") or "[]")
@@ -715,6 +742,7 @@ async def run_agent_stream(
                     config, stream_input, on_step=event_queue.put,
                     agent_dir=agent_dir, run_id=run_id,
                     continue_workspace=continue_workspace,
+                    work_dir=work_dir, source_dir=source_dir, run_dir=run_dir,
                 )
                 duration_ms = int((time.time() - t0) * 1000)
                 workspace_path = trace_info.get("workspace_path", "")
@@ -1425,24 +1453,49 @@ def _compile_agent(config: dict):
 
 def _execute_agent(config: dict, input_text: str, on_step=None,
                    agent_dir: str = "", run_id: str = "",
-                   continue_workspace: str = ""):
+                   continue_workspace: str = "", work_dir: str = "",
+                   source_dir: str = "", run_dir: str = ""):
     """Execute agent via lambdagent. Returns (result, trace_info).
 
-    Args:
-        config: Agent YAML config dict.
-        input_text: User prompt.
-        on_step: Optional callback for streaming.
-        agent_dir: Agent directory path — if set, creates workspace/run_{timestamp}/.
-        run_id: Run ID for workspace metadata.
-        continue_workspace: If set, reuse this existing workspace dir directly
-            instead of creating a fresh one. Used when the user clicks
-            "continue this run" — sub-agents see prior cycle_1/cycle_2/...
-            files via the normal {workspace} path, not via prompt indirection.
+    3-directory system
+    ─────────────────
+    source_dir : 数据来源目录. Read-only input data location.
+        When set, a preamble is injected into the system prompt informing
+        the agent where to find source documents/data.
+    work_dir   : 工作产物目录. Final-output directory; shell Bash CWD.
+        The agent writes its deliverables here.
+    run_dir    : 运行日志目录. Base for run workspace creation
+        (workspace/run_{ts}/ sub-dirs with traces, configs, intermediate
+        artifacts). Falls back to work_dir, then agent_dir.
+
+    Other args
+    ──────────
+    config: Agent YAML config dict.
+    input_text: User prompt.
+    on_step: Optional callback for streaming.
+    agent_dir: Agent template/system directory (fallback for run_dir).
+    run_id: Run ID for workspace metadata.
+    continue_workspace: If set, reuse this existing workspace dir directly.
     """
     import yaml, os
     from agentpaas.engine.sandbox import create_run_workspace, save_run_artifacts
 
-    # 创建 run workspace(如果有 agent_dir)
+    # ── source_dir injection ──────────────────────────────────────────────────
+    # Prepend a brief preamble to the system prompt so the agent knows where
+    # its input data lives. This is purely informational — the agent still
+    # uses normal Bash/Read tools to access those files.
+    if source_dir and os.path.isdir(source_dir):
+        preamble = (
+            f"[数据来源目录]\n"
+            f"你的输入数据位于:{source_dir}\n"
+            f"请优先从该目录读取所需文件。\n\n"
+        )
+        existing_prompt = config.get("systemPrompt") or ""
+        config = dict(config)  # shallow copy to avoid mutating caller's dict
+        config["systemPrompt"] = preamble + existing_prompt
+
+    # 创建 run workspace
+    # Priority: continue_workspace > run_dir > work_dir > agent_dir
     workspace_path = ""
     if continue_workspace and os.path.isdir(continue_workspace):
         # Reuse the previous run's workspace dir. Don't call
@@ -1473,24 +1526,37 @@ def _execute_agent(config: dict, input_text: str, on_step=None,
         except Exception as _e:
             logger.warning("could not write continue marker: %s", _e)
         logger.info("reusing workspace for continue-run: %s", workspace_path)
+    elif run_dir and os.path.isdir(run_dir):
+        # run_dir is the dedicated log/trace base; workspace lives DIRECTLY here
+        # (no extra "workspace/" subdir — the user already named this dir whatever
+        # they want, e.g. "…/review67/workspace"). bare=True skips the subdir.
+        workspace_path = create_run_workspace(run_dir, run_id, input_text, config, bare=True)
+    elif work_dir and os.path.isdir(work_dir):
+        # Fallback: create run workspace inside work_dir when run_dir is not set.
+        workspace_path = create_run_workspace(work_dir, run_id, input_text, config)
     elif agent_dir:
         workspace_path = create_run_workspace(agent_dir, run_id, input_text, config)
 
-    # CRITICAL: align the Bash tool's session CWD with this run's workspace.
-    # shell_tools._session_cwd is a *module-level* global captured at import
-    # time (= the backend's startup CWD, typically the repo root). Without
-    # this override every `{"tool":"Bash","input":{"command":"mkdir foo"}}`
-    # creates files under the repo, not the run workspace. This is process-
-    # global state — concurrent runs in the same process would race on it —
-    # which is acceptable for the current single-tenant deployment but worth
-    # revisiting (thread-local _session_cwd) before enabling parallel runs.
-    if workspace_path:
+    # CRITICAL: align the Bash tool's session CWD.
+    # Shell starts in work_dir (where final outputs go) so the agent
+    # naturally reads/writes user files there. workspace_path is the
+    # log/trace dir — {workspace} variable in prompts points there —
+    # but Bash starts in work_dir.
+    # When work_dir is absent, fall back to workspace_path (existing behaviour).
+    _cwd_to_set = (
+        work_dir if (work_dir and os.path.isdir(work_dir)) else workspace_path
+    )
+    if _cwd_to_set:
         try:
             from lambdagent.builtin_tools.shell_tools import _set_cwd as _shell_set_cwd
-            _shell_set_cwd(workspace_path)
-            logger.info("aligned shell_tools session CWD to workspace: %s", workspace_path)
+            _shell_set_cwd(_cwd_to_set)
+            logger.info(
+                "shell CWD → %s (%s)",
+                _cwd_to_set,
+                "work_dir" if _cwd_to_set == work_dir else "workspace",
+            )
         except Exception as _e:
-            logger.warning("failed to align shell session CWD: %s", _e)
+            logger.warning("failed to set shell session CWD: %s", _e)
 
     # 配置写入 workspace 或 /tmp
     if workspace_path:

+ 95 - 0
agentpaas/src/agentpaas/engine/builtin_packs.py

@@ -0,0 +1,95 @@
+"""
+agentpaas.engine.builtin_packs — auto-install built-in AgentPacks on first startup.
+
+Called from api/app.py ``_on_startup()`` in desktop mode (idempotent; skips
+packs that are already installed).  Never raises — logs warnings on failure so
+the server can still start even if a pack fails to install.
+
+Built-in pack sources: ``agentexample/agentpacks/`` (relative to repo root,
+resolved by walking up from this file).
+"""
+from __future__ import annotations
+
+import logging
+import os
+import tempfile
+import zipfile
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+BUILTIN_PACK_IDS: list[str] = [
+    "research.top-journal-reviewer",
+    "research.literature-mapper",
+    "research.grant-planner",
+]
+
+
+def _find_packs_src() -> Path | None:
+    """Walk up from this file to find ``agentexample/agentpacks/``."""
+    here = Path(__file__).resolve().parent
+    for _ in range(8):
+        candidate = here / "agentexample" / "agentpacks"
+        if candidate.is_dir():
+            return candidate
+        here = here.parent
+    # CWD fallback (running from repo root)
+    cwd_candidate = Path(os.getcwd()) / "agentexample" / "agentpacks"
+    if cwd_candidate.is_dir():
+        return cwd_candidate
+    return None
+
+
+def _zip_pack(pack_id: str, pack_dir: Path, dest_dir: Path) -> Path:
+    zip_path = dest_dir / f"{pack_id}.zip"
+    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+        for p in pack_dir.rglob("*"):
+            if p.is_file():
+                zf.write(p, p.relative_to(pack_dir))
+    return zip_path
+
+
+def ensure_builtin_packs_installed(packs_dir: str) -> None:
+    """Install any built-in packs that are not already present in *packs_dir*.
+
+    Safe to call every startup:
+    - Each pack is skipped if its ``id`` is already installed (any version).
+    - Exceptions per-pack are caught and logged; remaining packs are still
+      attempted.
+    - The directory is created if it doesn't exist yet.
+    """
+    from agentpaas.engine.agentpack_store import install_from_zip, get_installed
+
+    packs_src = _find_packs_src()
+    if not packs_src:
+        logger.debug(
+            "ensure_builtin_packs_installed: agentexample/agentpacks/ not found "
+            "(this is normal when running outside the repo); skipping auto-install"
+        )
+        return
+
+    os.makedirs(packs_dir, exist_ok=True)
+
+    with tempfile.TemporaryDirectory() as td:
+        for pack_id in BUILTIN_PACK_IDS:
+            try:
+                if get_installed(packs_dir, pack_id):
+                    logger.debug(f"builtin pack already installed: {pack_id}")
+                    continue
+
+                pack_dir = packs_src / pack_id
+                if not pack_dir.is_dir():
+                    logger.debug(
+                        f"builtin pack source dir not found: {pack_dir}; skipping"
+                    )
+                    continue
+
+                zip_path = _zip_pack(pack_id, pack_dir, Path(td))
+                pack = install_from_zip(str(zip_path), packs_dir)
+                logger.info(
+                    f"auto-installed builtin pack: {pack_id} v{pack.version}"
+                )
+            except Exception as exc:  # pragma: no cover — startup must not crash
+                logger.warning(
+                    f"failed to auto-install builtin pack {pack_id}: {exc}"
+                )

+ 11 - 3
agentpaas/src/agentpaas/engine/sandbox.py

@@ -50,11 +50,12 @@ def create_run_workspace(
     run_id: str,
     input_text: str,
     config: dict,
+    bare: bool = False,
 ) -> str:
     """
     为一次 run 创建持久化工作目录。
 
-    目录结构:
+    目录结构(bare=False,默认):
         {agent_dir}/workspace/run_{YYYYMMDD_HHMMSS}/
         ├── input.json      ← 本次输入
         ├── config.yml      ← 使用的配置快照
@@ -62,11 +63,15 @@ def create_run_workspace(
         ├── results/        ← agent 产生的数据
         └── final/          ← agent 最终产物
 
+    当 bare=True 时(run_dir 场景):
+        {agent_dir}/run_{YYYYMMDD_HHMMSS}/   ← 不再追加 workspace/ 子目录
+
     Args:
-        agent_dir: agent 的根目录
+        agent_dir: agent 的根目录(bare=False)或运行日志目录(bare=True)
         run_id: run 的唯一标识 (e.g. "run_a1b2c3d4e5f6")
         input_text: 用户输入
         config: agent YAML 配置字典
+        bare: 若 True,不追加 workspace/ 子目录,直接在 agent_dir 下建 run_* 目录
 
     Returns:
         workspace_path: 创建的 run 目录绝对路径
@@ -74,7 +79,10 @@ def create_run_workspace(
     import yaml
 
     timestamp = time.strftime("%Y%m%d_%H%M%S")
-    workspace = os.path.join(agent_dir, "workspace", f"run_{timestamp}")
+    if bare:
+        workspace = os.path.join(agent_dir, f"run_{timestamp}")
+    else:
+        workspace = os.path.join(agent_dir, "workspace", f"run_{timestamp}")
 
     # 如果同一秒内有多次 run(罕见),追加 run_id 后缀避免冲突
     if os.path.exists(workspace):

+ 144 - 1
lambdagent/src/lambdagent/fromconfig/compiler.py

@@ -511,6 +511,7 @@ def _create_provider(model_cfg: Dict):
         max_tokens=model_cfg.get("maxTokens", 4096),
         timeout=model_cfg.get("timeout", 600),
         context_window=model_cfg.get("contextWindow", 200000),
+        extra=model_cfg.get("extra") or {},   # e.g. claude_first_byte_timeout
     )
 
     if provider_name == "claude-code":
@@ -864,6 +865,35 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
         # ── Phase 2: Extract tool call ──
         selected_tool, tool_input = _extract_tool_call(str(thought), tools)
 
+        # ── Phase 2b: Nudge on unparseable tool call ──
+        # LLMs sometimes produce a syntactically broken tool-call JSON (e.g.
+        # raw newlines inside the content field of a WriteFile).
+        # _fix_json_control_chars handles the common case automatically, but
+        # if the JSON is still invalid we must NOT silently treat the step as
+        # "terminate" — that would discard the agent's intended action.
+        # Instead, inject a SYSTEM nudge asking it to reformat with proper
+        # escape sequences, then continue the loop.
+        if selected_tool is None and step < max_steps - 1:
+            thought_str = str(thought)
+            if '"tool"' in thought_str or '"action"' in thought_str:
+                nudge = (
+                    "[SYSTEM] 工具调用JSON格式错误,无法解析。\n"
+                    "最常见原因:content/message字段中包含了未转义的换行符。\n"
+                    "请严格按如下格式重新输出(content内换行用 \\n,引号用 \\\","
+                    "反斜线用 \\\\):\n"
+                    "{\"tool\":\"WriteFile\",\"input\":{\"path\":\"绝对路径\","
+                    "\"content\":\"第一行\\n第二行\\n\"}}"
+                )
+                _last_observation[0] = nudge
+                if verbose:
+                    print(f"  B[{step}] malformed JSON tool call — nudging LLM to reformat")
+                if _has_session:
+                    return f"{_user_input[0]}\n[Step {step+1}] json_parse_error"
+                else:
+                    return _compress_state(
+                        str(state), str(thought), "json_parse_error", nudge
+                    )
+
         # ── Phase 3: Termination check ──
         if selected_tool is None or selected_tool._name == "terminate":
             # HARD floor: enforceLoop forces a minimum number of calls to
@@ -1535,6 +1565,101 @@ def _compile_memory(agent: Term, memory_cfg: Dict, overrides: Dict) -> Term:
 # Tool call extraction (JSON > XML > Keyword)
 # ============================================================
 
+def _fix_json_control_chars(text: str) -> str:
+    """Escape raw control characters AND unescaped quotes inside JSON strings.
+
+    LLMs (including Claude Code) frequently produce syntactically broken JSON
+    tool calls, particularly for WriteFile/WriteCode actions where the content
+    field contains:
+
+    1. *Raw control characters* — actual newline / carriage-return / tab
+       characters instead of the JSON-mandated ``\\n`` / ``\\r`` / ``\\t``
+       escape sequences.
+
+    2. *Unescaped double-quotes* — e.g. the review text contains
+       ``文献值"不能直接用于本样品"`` with literal ASCII ``"`` characters that
+       should have been written as ``\\"`` inside the JSON string value.
+
+    This helper does a single linear pass that:
+    - Tracks whether the scanner is inside a JSON string.
+    - Replaces bare control characters (\\n / \\r / \\t) inside strings with
+      their two-char escape equivalents.
+    - For ``"``: if we're inside a string, peek at the next non-whitespace
+      character.  If it is a JSON structural character (``:`` ``,`` ``}``
+      ``]``), treat the ``"`` as a closing delimiter (end of string).
+      Otherwise, treat it as unescaped content and emit ``\\"``.
+
+    The lookahead heuristic is conservative — it only escapes quotes that are
+    NOT immediately followed by a JSON structural token.  For the typical
+    WriteFile payload (a long Markdown/text blob as the last field before the
+    closing ``"}}``) this reliably distinguishes content quotes from the
+    closing delimiter.
+
+    Called only *after* a first parse attempt fails, so the fast path is
+    unaffected.
+    """
+    result: list = []
+    i = 0
+    n = len(text)
+    in_string = False
+    prev_was_backslash = False
+
+    while i < n:
+        c = text[i]
+
+        # Already-escaped character: pass through verbatim
+        if prev_was_backslash:
+            prev_was_backslash = False
+            result.append(c)
+            i += 1
+            continue
+
+        # Backslash inside a string starts an escape sequence
+        if c == '\\' and in_string:
+            prev_was_backslash = True
+            result.append(c)
+            i += 1
+            continue
+
+        # Double-quote — could be string boundary or unescaped content
+        if c == '"':
+            if not in_string:
+                # Opening a new string
+                in_string = True
+                result.append(c)
+            else:
+                # Peek ahead: skip whitespace, then check for JSON structural char
+                j = i + 1
+                while j < n and text[j] in ' \t\r\n':
+                    j += 1
+                if j < n and text[j] in ':,}]':
+                    # Closing delimiter — end the string
+                    in_string = False
+                    result.append(c)
+                else:
+                    # Unescaped quote inside string content — escape it
+                    result.append('\\"')
+            i += 1
+            continue
+
+        # Inside a string: fix raw control characters
+        if in_string:
+            if c == '\n':
+                result.append('\\n')
+            elif c == '\r':
+                result.append('\\r')
+            elif c == '\t':
+                result.append('\\t')
+            else:
+                result.append(c)
+        else:
+            result.append(c)
+
+        i += 1
+
+    return ''.join(result)
+
+
 def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
     """
     Extract tool call from LLM output.
@@ -1551,11 +1676,15 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
     #   1. If a ```json ... ``` fence exists, scan inside it first.
     #   2. Otherwise scan the entire thought for the first valid JSON object
     #      that names a known tool.
+    #   3. If parse fails at a `{`, retry after fixing unescaped control chars
+    #      (LLMs often embed multiline content with raw newlines — see
+    #      _fix_json_control_chars above).
     def _scan_for_json_action(text: str):
         decoder = json.JSONDecoder()
         i = 0
         while i < len(text):
             if text[i] == '{':
+                # ── Attempt 1: strict parse ──
                 try:
                     obj, _end = decoder.raw_decode(text, i)
                     if isinstance(obj, dict):
@@ -1563,7 +1692,21 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
                         if result:
                             return result
                 except json.JSONDecodeError:
-                    pass
+                    # ── Attempt 2: fix raw control chars in strings, retry ──
+                    # Only bother when the substring looks like a tool call
+                    # (contains "tool" or "action") to avoid wasting time on
+                    # every stray `{` in the text.
+                    snippet = text[i:i + 30]
+                    if '"tool"' in snippet or '"action"' in snippet:
+                        try:
+                            fixed = _fix_json_control_chars(text[i:])
+                            obj, _end = decoder.raw_decode(fixed, 0)
+                            if isinstance(obj, dict):
+                                result = _parse_json_action(obj, tools)
+                                if result:
+                                    return result
+                        except json.JSONDecodeError:
+                            pass
             i += 1
         return None
 

+ 104 - 27
lambdagent/src/lambdagent/providers/claude_code_provider.py

@@ -272,15 +272,22 @@ def _find_working_claude(preferred: str = "claude") -> Optional[str]:
     return found or preferred
 
 
-def _strip_tool_docs(system_prompt: str) -> str:
-    """Remove the auto-generated tool-schema section from a system prompt.
+def _strip_tool_docs(system_prompt: str) -> tuple:
+    """Strip the auto-generated tool-schema section and extract tool names.
 
     _generate_tool_schema_docs() appends a '## 工具参数参考' block that
     lists Bash, WriteFile, etc. with JSON call signatures.  When this reaches
     claude-code via --system-prompt, claude decides it *has* those native tools
-    and tries to use them directly instead of outputting a JSON routing decision.
-    Stripping the section before the subprocess call fixes the hang.
+    and tries to use them directly.  We strip the full schema block (to stop
+    that), but extract the tool names so we can re-inject a lightweight
+    "these tools are available via JSON format" notice — without that notice,
+    claude only sees its MCP session tools (Gmail, Drive, …) and gives up.
+
+    Returns:
+        (stripped_prompt, tool_names_list)
     """
+    import re as _re
+    tool_names: list = []
     for marker in (
         "\n\n## 工具参数参考",   # Chinese header added by _generate_tool_schema_docs
         "\n\n## Available Tools",
@@ -289,8 +296,12 @@ def _strip_tool_docs(system_prompt: str) -> str:
     ):
         idx = system_prompt.find(marker)
         if idx != -1:
-            return system_prompt[:idx]
-    return system_prompt
+            # Extract tool names from the block before discarding it
+            block = system_prompt[idx:]
+            # Matches "- **ToolName**:" or "**terminate**:"
+            tool_names = _re.findall(r'\*\*(\w+)\*\*', block)
+            return system_prompt[:idx], tool_names
+    return system_prompt, tool_names
 
 
 class ClaudeCodeProvider(LLMProvider):
@@ -335,6 +346,26 @@ class ClaudeCodeProvider(LLMProvider):
         "\n格式:{\"tool\": \"工具名\", \"input\": \"...\"} 或 terminate"
     )
 
+    @staticmethod
+    def _build_tool_notice(tool_names: list) -> str:
+        """Build a lightweight notice listing available JSON-format tools.
+
+        This is injected AFTER the schema block is stripped so Claude knows
+        which tools to route through the JSON call format — without this,
+        claude sees only its MCP session tools (Gmail, Drive, …) and gives up
+        instead of emitting JSON tool calls.
+        """
+        if not tool_names:
+            return ""
+        names = "、".join(tool_names)
+        return (
+            f"\n\n[可用工具(JSON格式调用,与当前MCP工具无关)]\n"
+            f"以下工具通过 {{\"tool\":\"工具名\",\"input\":...}} JSON格式调用,"
+            f"不是当前session的MCP原生工具:\n{names}\n"
+            f"当你需要读文件、列目录、写文件时,请用上述JSON格式调用对应工具,"
+            f"不要说【工具不可用】,直接用JSON格式调用即可。"
+        )
+
     def _call_new(self, messages: List[Dict[str, str]]) -> str:
         """First call: create session with system prompt, capture session_id."""
         system_prompt = ""
@@ -346,8 +377,12 @@ class ClaudeCodeProvider(LLMProvider):
                 user_content = m["content"]
 
         # Strip tool-schema docs (## 工具参数参考 section) — they expose Bash/WriteFile
-        # which makes claude-code try to use them natively instead of outputting JSON.
-        effective_system = _strip_tool_docs(system_prompt) + self._SYSTEM_PROMPT_SUFFIX
+        # which makes claude-code try to use them natively. Extract tool names first so
+        # we can re-inject a lightweight "these tools exist via JSON" notice, which stops
+        # Claude from looking at its MCP session tools and giving up.
+        stripped_prompt, tool_names = _strip_tool_docs(system_prompt)
+        tool_notice = self._build_tool_notice(tool_names)
+        effective_system = stripped_prompt + tool_notice + self._SYSTEM_PROMPT_SUFFIX
         prompt_arg = user_content + self._REACT_CONSTRAINT
 
         cmd = [
@@ -380,24 +415,58 @@ class ClaudeCodeProvider(LLMProvider):
             self.config.model, self.config.timeout, idle_timeout, first_byte_timeout,
         )
 
+        # First-turn STALLs are usually transient Anthropic-side hiccups
+        # (silent throttle during burst, transient 5xx). audit on
+        # 2026-06-06 showed 38% (19/50) first-turn STALL rate when the
+        # API got flaky — most of those would have succeeded on a second
+        # try. We retry ONCE with the same params before giving up;
+        # second attempt usually completes within normal latency.
+        #
+        # NOTE: this retry burns up to first_byte_timeout (120s default)
+        # in the worst case. If both attempts stall, the engine still
+        # gets a retryable ProviderError so its own logic can decide
+        # whether to give up the whole step.
+        max_attempts = 2
+        last_stall: Optional[_StallError] = None
+        result = None
         t0 = time.time()
-        try:
-            result = _run_with_idle_timeout(
-                cmd,
-                idle_timeout=idle_timeout,
-                first_byte_timeout=first_byte_timeout,
-                hard_timeout=float(self.config.timeout),
-            )
-        except _StallError as exc:
-            logger.warning(
-                "claude-code STALL (%s) after %.1fs partial_stdout=%r partial_stderr=%r",
-                exc.kind, exc.elapsed, exc.stdout[:300], exc.stderr[:300],
-            )
+        for attempt in range(1, max_attempts + 1):
+            try:
+                result = _run_with_idle_timeout(
+                    cmd,
+                    idle_timeout=idle_timeout,
+                    first_byte_timeout=first_byte_timeout,
+                    hard_timeout=float(self.config.timeout),
+                )
+                if attempt > 1:
+                    logger.info(
+                        "claude-code first-turn ok on retry %d after %.1fs total",
+                        attempt, time.time() - t0,
+                    )
+                last_stall = None
+                break
+            except _StallError as exc:
+                last_stall = exc
+                logger.warning(
+                    "claude-code STALL (%s, first-turn attempt %d/%d) after %.1fs "
+                    "partial_stdout=%r partial_stderr=%r",
+                    exc.kind, attempt, max_attempts, exc.elapsed,
+                    exc.stdout[:300], exc.stderr[:300],
+                )
+                if attempt < max_attempts:
+                    # Brief pause so any burst-window throttle on Anthropic's
+                    # side has a chance to reset. Tested: 2s is enough; 0s
+                    # often re-STALLs back-to-back.
+                    time.sleep(2.0)
+
+        if last_stall is not None:
             raise ProviderError(
-                f"Claude Code stalled ({exc.kind}-timeout after {exc.elapsed:.0f}s, "
-                f"no output produced) on first turn",
+                f"Claude Code stalled ({last_stall.kind}-timeout after "
+                f"{last_stall.elapsed:.0f}s, no output produced) on first turn "
+                f"(retried {max_attempts}x)",
                 "claude-code", retryable=True,
             )
+        assert result is not None  # for type checker; loop guarantees this
 
         elapsed = time.time() - t0
         if result.returncode != 0:
@@ -505,10 +574,18 @@ class ClaudeCodeProvider(LLMProvider):
                     stall_exc.kind, attempt, elapsed,
                     stall_exc.stdout[:300], stall_exc.stderr[:300],
                 )
-                # Stalls (especially "idle" with no bytes) indicate the
-                # upstream is wedged — retrying just burns another ~60s.
-                # Clear the session and fail; the engine will inject a
-                # fresh first-turn that preserves the user_input.
+                # first-byte stalls on attempt 1 are often just slow API
+                # responses for large-context sessions (e.g. long PDF reviews)
+                # — retry once before giving up. idle/hard stalls (session
+                # wedged / hit hard ceiling) and second first-byte stalls
+                # are unrecoverable: clear session and fail.
+                if stall_exc.kind == "first-byte" and attempt == 1:
+                    logger.info(
+                        "claude-code resume first-byte stall on attempt 1 — "
+                        "retrying (large context may need extra time)"
+                    )
+                    time.sleep(2.0)
+                    continue
                 self._session_id = None
                 raise ProviderError(
                     f"Claude Code stalled ({stall_exc.kind}-timeout after "
@@ -533,7 +610,7 @@ class ClaudeCodeProvider(LLMProvider):
                     "claude-code RESUME transport error (attempt 1) exit=%d after %.1fs — retrying in 2s. detail=%r",
                     result.returncode, elapsed, detail[:300],
                 )
-                _time.sleep(2)
+                time.sleep(2)
                 continue  # Keep session_id; retry the same resume
 
             logger.warning(

+ 116 - 0
tests/test_builtin_packs.py

@@ -0,0 +1,116 @@
+"""
+tests/test_builtin_packs.py — Unit tests for builtin_packs.ensure_builtin_packs_installed().
+
+Verifies:
+  - All 3 research packs are installed into a fresh directory.
+  - Second call is idempotent (no re-install, no error).
+  - Missing source dir is handled gracefully (no exception).
+  - Per-pack failures don't abort the remaining installs.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from agentpaas.engine.builtin_packs import (
+    BUILTIN_PACK_IDS,
+    ensure_builtin_packs_installed,
+)
+from agentpaas.engine.agentpack_store import get_installed, list_installed
+
+
+# ── helpers ──────────────────────────────────────────────────────────────────
+
+
+def _packs_src() -> Path | None:
+    """Locate agentexample/agentpacks/ relative to repo root."""
+    here = Path(__file__).resolve().parent.parent  # repo root
+    candidate = here / "agentexample" / "agentpacks"
+    return candidate if candidate.is_dir() else None
+
+
+# ── tests ─────────────────────────────────────────────────────────────────────
+
+
+@pytest.mark.skipif(
+    _packs_src() is None,
+    reason="agentexample/agentpacks/ not present (running outside repo)",
+)
+def test_ensure_installs_all_builtin_packs(tmp_path):
+    """Fresh packs_dir → all 3 built-in packs installed."""
+    packs_dir = str(tmp_path / "packs")
+    ensure_builtin_packs_installed(packs_dir)
+
+    installed_ids = {p.id for p in list_installed(packs_dir)}
+    for pack_id in BUILTIN_PACK_IDS:
+        assert pack_id in installed_ids, f"pack {pack_id} was not installed"
+
+
+@pytest.mark.skipif(
+    _packs_src() is None,
+    reason="agentexample/agentpacks/ not present",
+)
+def test_ensure_idempotent(tmp_path):
+    """Calling twice does not raise and does not corrupt the installs."""
+    packs_dir = str(tmp_path / "packs")
+
+    ensure_builtin_packs_installed(packs_dir)
+    first_count = len(list_installed(packs_dir))
+
+    ensure_builtin_packs_installed(packs_dir)  # second call
+    second_count = len(list_installed(packs_dir))
+
+    assert first_count == second_count == len(BUILTIN_PACK_IDS)
+
+
+def test_ensure_graceful_when_no_source_dir(tmp_path):
+    """If agentexample/agentpacks/ cannot be found, no exception is raised."""
+    packs_dir = str(tmp_path / "packs")
+
+    with patch(
+        "agentpaas.engine.builtin_packs._find_packs_src",
+        return_value=None,
+    ):
+        # Must not raise
+        ensure_builtin_packs_installed(packs_dir)
+
+    # Nothing installed (source was unavailable)
+    assert list_installed(packs_dir) == []
+
+
+@pytest.mark.skipif(
+    _packs_src() is None,
+    reason="agentexample/agentpacks/ not present",
+)
+def test_ensure_continues_after_per_pack_failure(tmp_path, monkeypatch):
+    """A failure in the first pack does not block the remaining two."""
+    packs_dir = str(tmp_path / "packs")
+    first_pack = BUILTIN_PACK_IDS[0]
+    rest = BUILTIN_PACK_IDS[1:]
+
+    import agentpaas.engine.agentpack_store as _store
+
+    original_get = _store.get_installed
+    call_count = {"n": 0}
+
+    def patched_get(d, pid, v=None):
+        call_count["n"] += 1
+        if pid == first_pack and call_count["n"] == 1:
+            raise RuntimeError("simulated failure for first pack")
+        return original_get(d, pid, v)
+
+    # Patch the function on the source module so the local import inside
+    # ensure_builtin_packs_installed() picks up the patched version.
+    monkeypatch.setattr(_store, "get_installed", patched_get)
+
+    # Must not raise even though first pack fails
+    ensure_builtin_packs_installed(packs_dir)
+
+    installed_ids = {p.id for p in list_installed(packs_dir)}
+    for pack_id in rest:
+        assert pack_id in installed_ids, (
+            f"pack {pack_id} should have been installed despite earlier failure"
+        )

+ 2 - 0
webui/src/App.tsx

@@ -12,6 +12,7 @@ import Providers from './pages/Providers'
 import Knowledge from './pages/Knowledge'
 import KnowledgeDetail from './pages/KnowledgeDetail'
 import AgentPacks from './pages/AgentPacks'
+import AgentWorkspace from './pages/AgentWorkspace'
 
 function RequireAuth({ children }: { children: React.ReactNode }) {
   const apiKey = useAppStore(s => s.apiKey)
@@ -38,6 +39,7 @@ export default function App() {
           <Route path="agents/new" element={<AgentCreate />} />
           <Route path="agents/import" element={<AgentImport />} />
           <Route path="agents/:agentId/edit" element={<AgentEdit />} />
+          <Route path="agents/:agentId/workspace" element={<AgentWorkspace />} />
           <Route path="chat/:agentId" element={<Chat />} />
           <Route path="settings/providers" element={<Providers />} />
           <Route path="knowledge" element={<Knowledge />} />

+ 204 - 0
webui/src/components/DirPickerModal.tsx

@@ -0,0 +1,204 @@
+/**
+ * DirPickerModal — 本地目录选择弹窗(调用后端 /setup/browse-dir)
+ *
+ * 用法:
+ *   <DirPickerModal
+ *     initial="/Users/me/papers"
+ *     onSelect={(path) => setWorkDir(path)}
+ *     onClose={() => setPickerOpen(false)}
+ *   />
+ */
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import {
+  Folder,
+  FolderOpen,
+  ChevronRight,
+  Home,
+  ArrowLeft,
+  Eye,
+  EyeOff,
+  Check,
+} from 'lucide-react'
+import { api } from '../api/client'
+import { Spinner } from './ui'
+
+interface DirEntry {
+  name: string
+  path: string
+  hidden: boolean
+}
+
+interface BrowseResult {
+  current: string
+  parent: string | null
+  home: string
+  entries: DirEntry[]
+}
+
+function fetchBrowse(path: string): Promise<BrowseResult> {
+  return api.get(`/setup/browse-dir?path=${encodeURIComponent(path)}`)
+}
+
+interface Props {
+  initial?: string
+  onSelect: (path: string) => void
+  onClose: () => void
+}
+
+export default function DirPickerModal({ initial, onSelect, onClose }: Props) {
+  const [current, setCurrent] = useState(initial || '~')
+  const [showHidden, setShowHidden] = useState(false)
+
+  const { data, isLoading, error } = useQuery<BrowseResult>({
+    queryKey: ['browse-dir', current],
+    queryFn: () => fetchBrowse(current),
+    staleTime: 5_000,
+    retry: 1,
+  })
+
+  const entries = (data?.entries ?? []).filter(e => showHidden || !e.hidden)
+
+  /** Render the current path as clickable breadcrumb segments. */
+  function Breadcrumb() {
+    if (!data) return null
+    const parts = data.current.split('/').filter(Boolean)
+    const segments = parts.map((seg, i) => ({
+      label: seg,
+      path: '/' + parts.slice(0, i + 1).join('/'),
+    }))
+    return (
+      <div className="flex items-center gap-0.5 flex-wrap text-xs text-gray-500 min-h-[20px]">
+        <button
+          onClick={() => setCurrent('/')}
+          className="hover:text-indigo-600 px-1 rounded"
+        >
+          /
+        </button>
+        {segments.map((s, i) => (
+          <span key={s.path} className="flex items-center gap-0.5">
+            <ChevronRight size={11} className="text-gray-300 shrink-0" />
+            <button
+              onClick={() => setCurrent(s.path)}
+              className={
+                i === segments.length - 1
+                  ? 'font-semibold text-gray-800 px-1 rounded'
+                  : 'hover:text-indigo-600 px-1 rounded'
+              }
+            >
+              {s.label}
+            </button>
+          </span>
+        ))}
+      </div>
+    )
+  }
+
+  return (
+    <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]">
+      <div className="bg-white rounded-xl shadow-2xl w-full max-w-md flex flex-col" style={{ height: '520px' }}>
+        {/* Header */}
+        <div className="flex items-center justify-between px-4 pt-4 pb-2 border-b border-gray-100">
+          <h3 className="text-sm font-semibold text-gray-900">选择工作目录</h3>
+          <button
+            onClick={onClose}
+            className="text-gray-400 hover:text-gray-600 text-lg leading-none"
+          >
+            ×
+          </button>
+        </div>
+
+        {/* Toolbar */}
+        <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-100 bg-gray-50">
+          {/* Back */}
+          <button
+            onClick={() => data?.parent && setCurrent(data.parent)}
+            disabled={!data?.parent}
+            className="p-1.5 rounded hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed"
+            title="上级目录"
+          >
+            <ArrowLeft size={14} />
+          </button>
+          {/* Home */}
+          <button
+            onClick={() => data?.home && setCurrent(data.home)}
+            className="p-1.5 rounded hover:bg-gray-200"
+            title="主目录"
+          >
+            <Home size={14} />
+          </button>
+
+          {/* Breadcrumb */}
+          <div className="flex-1 overflow-hidden">
+            <Breadcrumb />
+          </div>
+
+          {/* Show/hide hidden */}
+          <button
+            onClick={() => setShowHidden(v => !v)}
+            className={`p-1.5 rounded hover:bg-gray-200 ${showHidden ? 'text-indigo-600' : 'text-gray-400'}`}
+            title={showHidden ? '隐藏隐藏文件夹' : '显示隐藏文件夹'}
+          >
+            {showHidden ? <Eye size={14} /> : <EyeOff size={14} />}
+          </button>
+        </div>
+
+        {/* Directory list */}
+        <div className="flex-1 overflow-y-auto px-2 py-2">
+          {isLoading && (
+            <div className="flex justify-center py-8"><Spinner size={20} /></div>
+          )}
+          {error && (
+            <p className="text-xs text-red-500 px-2 py-4 text-center">
+              无法读取目录
+            </p>
+          )}
+          {!isLoading && !error && entries.length === 0 && (
+            <p className="text-xs text-gray-400 px-2 py-4 text-center">
+              此目录下没有子目录
+            </p>
+          )}
+          {entries.map(entry => (
+            <button
+              key={entry.path}
+              onDoubleClick={() => setCurrent(entry.path)}
+              onClick={() => setCurrent(entry.path)}
+              className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-left text-sm transition-colors
+                ${current === entry.path
+                  ? 'bg-indigo-50 text-indigo-700'
+                  : 'hover:bg-gray-50 text-gray-700'
+                }
+                ${entry.hidden ? 'opacity-60 italic' : ''}
+              `}
+            >
+              {current === entry.path
+                ? <FolderOpen size={15} className="text-indigo-500 shrink-0" />
+                : <Folder size={15} className="text-amber-500 shrink-0" />
+              }
+              <span className="truncate">{entry.name}</span>
+              <ChevronRight size={12} className="ml-auto text-gray-300 shrink-0" />
+            </button>
+          ))}
+        </div>
+
+        {/* Selected path preview + confirm */}
+        <div className="px-4 py-3 border-t border-gray-100 bg-gray-50 rounded-b-xl">
+          <p className="text-[11px] text-gray-500 mb-2 font-medium">已选目录</p>
+          <div className="flex items-center gap-2">
+            <code className="flex-1 text-xs bg-white border border-gray-200 rounded-lg px-3 py-2 text-gray-800 truncate font-mono">
+              {data?.current ?? current}
+            </code>
+            <button
+              onClick={() => { onSelect(data?.current ?? current); onClose() }}
+              disabled={!data?.current}
+              className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 disabled:opacity-40 shrink-0"
+            >
+              <Check size={14} />
+              选择
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 12 - 3
webui/src/pages/AgentWorkspace.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { useParams, useNavigate } from 'react-router-dom'
+import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
 import { useQuery } from '@tanstack/react-query'
 import {
   ArrowLeft, Folder, FolderOpen, File, FileText, FileCode2,
@@ -256,9 +256,13 @@ function FilePreview({
 export default function AgentWorkspace() {
   const { agentId } = useParams<{ agentId: string }>()
   const navigate = useNavigate()
+  const [searchParams] = useSearchParams()
   const { apiKey } = useAppStore()
 
-  const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
+  // ?run=<runId> lets Chat.tsx deep-link directly to a specific run's workspace
+  const urlRunId = searchParams.get('run') || null
+
+  const [selectedRunId, setSelectedRunId] = useState<string | null>(urlRunId)
   const [selectedFile, setSelectedFile] = useState<WorkspaceFile | null>(null)
 
   // Load agent info
@@ -277,13 +281,18 @@ export default function AgentWorkspace() {
   })
 
   const allRuns = runsData?.runs ?? []
-  // Auto-select first run with workspace on load
+  // Auto-select first run with workspace on load (skip if URL already specifies one)
   useEffect(() => {
     if (selectedRunId) return
     const first = allRuns.find(r => r.workspace_path)
     if (first) setSelectedRunId(first.id)
   }, [allRuns, selectedRunId])
 
+  // When URL run param changes (navigation), sync selection
+  useEffect(() => {
+    if (urlRunId) setSelectedRunId(urlRunId)
+  }, [urlRunId])
+
   // Reset file selection when run changes
   useEffect(() => { setSelectedFile(null) }, [selectedRunId])
 

+ 136 - 14
webui/src/pages/Chat.tsx

@@ -70,15 +70,28 @@ function StepBlock({ step }: { step: ThinkStep }) {
 
 // ── Workspace panel ───────────────────────────────────────────────
 
-function WorkspacePanel({ agentId, runId, apiKey }: { agentId: string; runId: string; apiKey: string }) {
+function WorkspacePanel({
+  agentId, runId, apiKey, workspacePath,
+}: {
+  agentId: string
+  runId: string
+  apiKey: string
+  workspacePath?: string
+}) {
   const [open, setOpen] = useState(false)
+  const navigate = useNavigate()
+
+  // Eagerly fetch file count so the header badge is populated immediately
   const { data, isLoading } = useQuery({
     queryKey: ['workspace', agentId, runId],
     queryFn: () => agentsApi.workspaceFiles(agentId, runId),
-    enabled: open,
     staleTime: 60_000,
   })
 
+  const shortPath = workspacePath
+    ? workspacePath.split('/').slice(-2).join('/')   // "workspace/run_YYYYMMDD_HHmmss"
+    : null
+
   async function download(file: WorkspaceFile) {
     const url = agentsApi.workspaceDownloadUrl(agentId, runId, file.path)
     const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })
@@ -100,15 +113,40 @@ function WorkspacePanel({ agentId, runId, apiKey }: { agentId: string; runId: st
 
   return (
     <div className="mt-2 border border-gray-200 rounded-lg overflow-hidden text-xs">
-      <button
-        onClick={() => setOpen(o => !o)}
-        className="flex items-center gap-2 w-full px-3 py-2 bg-gray-50 hover:bg-gray-100 text-gray-600 transition-colors"
-      >
-        <Folder size={12} className="text-amber-500" />
-        <span className="font-medium">工作区文件</span>
-        {data && <span className="text-gray-400">({data.files.length} 个)</span>}
-        {open ? <ChevronDown size={12} className="ml-auto" /> : <ChevronRight size={12} className="ml-auto" />}
-      </button>
+      {/* Header: path · file count · 查看 · expand */}
+      <div className="flex items-center gap-2 px-3 py-2 bg-gray-50 hover:bg-gray-100 transition-colors">
+        <Folder size={12} className="text-emerald-500 shrink-0" />
+
+        {/* Path + count — click to expand inline file list */}
+        <button
+          onClick={() => setOpen(o => !o)}
+          className="flex items-center gap-1.5 flex-1 text-left min-w-0"
+        >
+          {shortPath
+            ? <code className="font-mono text-gray-600 truncate" title={workspacePath}>{shortPath}</code>
+            : <span className="text-gray-500">工作区文件</span>
+          }
+          {isLoading
+            ? <span className="text-gray-400 shrink-0">…</span>
+            : data && <span className="text-gray-400 shrink-0">({data.files.length} 个)</span>
+          }
+          {open
+            ? <ChevronDown size={11} className="ml-1 text-gray-400 shrink-0" />
+            : <ChevronRight size={11} className="ml-1 text-gray-400 shrink-0" />
+          }
+        </button>
+
+        {/* Single "查看" — opens workspace browser scoped to this run */}
+        <button
+          onClick={() => navigate(`/agents/${agentId}/workspace?run=${runId}`)}
+          className="text-indigo-500 hover:text-indigo-700 shrink-0 font-medium"
+          title={`在工作区浏览器查看 ${workspacePath ?? ''}`}
+        >
+          查看
+        </button>
+      </div>
+
+      {/* Inline file list */}
       {open && (
         <div className="bg-white divide-y divide-gray-50">
           {isLoading && (
@@ -116,7 +154,7 @@ function WorkspacePanel({ agentId, runId, apiKey }: { agentId: string; runId: st
               <Spinner size={12} /> 加载中…
             </div>
           )}
-          {data?.files.length === 0 && (
+          {!isLoading && data?.files.length === 0 && (
             <div className="px-3 py-2 text-gray-400">暂无文件</div>
           )}
           {data?.files.map(f => (
@@ -302,8 +340,15 @@ function MessageBubble({
 
         {/* Workspace file panel + Continue button — only after run completes */}
         {!isUser && msg.status === 'done' && msg.runId && (
-          <div className="space-y-1 w-full">
-            <WorkspacePanel agentId={agentId} runId={msg.runId} apiKey={apiKey} />
+          <div className="space-y-1.5 w-full">
+            {/* Single panel: shows path, file count, and "查看" link all in one */}
+            <WorkspacePanel
+              agentId={agentId}
+              runId={msg.runId}
+              apiKey={apiKey}
+              workspacePath={msg.workspacePath}
+            />
+
             {onContinue && (
               <button
                 onClick={() => onContinue(msg.runId!, msg.workspacePath ?? '')}
@@ -1105,6 +1150,17 @@ export default function Chat() {
         </button>
       </div>
 
+      {/* Directory info bar — shown only when any dir is configured */}
+      {(agent?.source_dir || agent?.work_dir || agent?.run_dir) && (
+        <DirInfoBar
+          sourceDir={agent.source_dir}
+          workDir={agent.work_dir}
+          runDir={agent.run_dir}
+          agentId={agentId!}
+          onEdit={() => navigate(`/agents/${agentId}/edit`)}
+        />
+      )}
+
       {/* Body — messages + optional memory panel */}
       <div className="flex-1 relative overflow-hidden">
         {/* Messages area */}
@@ -1373,3 +1429,69 @@ export default function Chat() {
     </div>
   )
 }
+
+// ── Directory info bar ────────────────────────────────────────────
+// Shown beneath the chat header when the agent has any dir configured.
+// Shows only the 3 static directories (source / work / run).
+// Per-run workspace paths are shown inside each message bubble instead.
+
+function DirInfoBar({
+  sourceDir, workDir, runDir, agentId, onEdit,
+}: {
+  sourceDir?: string
+  workDir?: string
+  runDir?: string
+  agentId: string
+  onEdit: () => void
+}) {
+  const [open, setOpen] = useState(false)
+
+  const previewItems = [
+    sourceDir && { label: '来源', value: sourceDir, color: 'text-blue-600' },
+    workDir   && { label: '产物', value: workDir,   color: 'text-amber-600' },
+    runDir    && { label: '日志', value: runDir,    color: 'text-emerald-600' },
+  ].filter(Boolean) as { label: string; value: string; color: string }[]
+
+  return (
+    <div className="border-b border-gray-100 bg-gray-50 shrink-0">
+      <button
+        onClick={() => setOpen(o => !o)}
+        className="w-full flex items-center gap-2 px-6 py-1.5 text-left hover:bg-gray-100 transition-colors"
+      >
+        <Folder size={11} className="text-gray-400 shrink-0" />
+        <span className="flex-1 flex items-center gap-3 text-[11px] text-gray-500 truncate">
+          {previewItems.map(d => (
+            <span key={d.label} className="flex items-center gap-1">
+              <span className="text-gray-400">{d.label}:</span>
+              <code className={`font-mono truncate max-w-[180px] ${d.color}`}>{d.value}</code>
+            </span>
+          ))}
+        </span>
+        {open
+          ? <ChevronDown size={11} className="text-gray-400 shrink-0" />
+          : <ChevronRight size={11} className="text-gray-400 shrink-0" />
+        }
+      </button>
+      {open && (
+        <div className="px-6 pb-3 pt-1 space-y-1.5">
+          {[
+            { label: '① 数据来源目录', value: sourceDir, color: 'text-blue-600' },
+            { label: '② 工作产物目录', value: workDir,   color: 'text-amber-600' },
+            { label: '③ 运行日志目录', value: runDir,    color: 'text-emerald-600' },
+          ].map(d => d.value ? (
+            <div key={d.label} className="flex items-start gap-2 text-xs">
+              <span className="text-gray-500 shrink-0 w-28">{d.label}</span>
+              <code className={`font-mono break-all ${d.color}`}>{d.value}</code>
+            </div>
+          ) : null)}
+          <button
+            onClick={onEdit}
+            className="mt-1 text-[11px] text-indigo-500 hover:text-indigo-700 underline underline-offset-2"
+          >
+            修改目录配置 →
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}