Преглед изворни кода

feat(workspace): 原生 claude-code 执行车道 — 治 react-over-text 封装的根因

设计见 docs/NATIVE_CLAUDE_CODE_DESIGN.md。

问题:workspace.assistant 被套进 react-over-text 封装(claude -p --tools "" → 纯
文本 → 平台正则抠 {"tool"} 自己执行 + 自管 session)。实测不如直接调一次 claude-code:
模型把工具调用当散文手写 JSON、格式飘 → 平台 76 调用 0 执行 0 落盘;我们的 session
轮换在 API 高延迟下狂触发(14 session/40min)清光上下文 → 空转到步数耗尽。根因=
用错模具:react-over-text 是给"必须派活给子智能体"的 orchestrator 设计的,单体助手
套进去全是成本没有收益。

方案:为工作区对话型 agent 开第二条车道,让 claude-code 当 agent 本身——
- 新增 providers/claude_code_native.py:`claude -p --output-format stream-json
  --add-dir <文件夹> --allowedTools Read/Write/Edit/Bash/... --strict-mcp-config
  --permission-mode bypassPermissions`(cwd=文件夹,native 工具开,无 --tools "")。
  解析 stream-json → 映射到现有 SSE 事件(think_chunk/tool_call/tool_result)→ 前端零改。
  抽取 result/usage/cost/session;复用 _is_auth_error/_AUTH_HINT。
- agents.py:_execute_agent 加 native_workspace 分支 → _run_native_workspace;
  run_agent(_stream) 对 agent_template ∈ {workspace.assistant} 传 native_workspace=True。
  orchestrator/科研/pipeline 车道完全不动。

**live 验证通过**:真起 claude -p 在临时目录写出 hello.txt(2 轮、tool_call/result
事件、cost/session 正确)。回归 test_native(9)+ lambdagent 588 + agentpaas 20 全绿。
P1 留:Bash 硬沙箱、session 持久化升 DB、native_allow_mcp 配置面。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju пре 2 месеци
родитељ
комит
6758ff8

+ 96 - 1
agentpaas/src/agentpaas/api/v1/agents.py

@@ -615,6 +615,8 @@ async def run_agent(
     _inplace_dir = _ctx_wd if (_ctx_wd and _os.path.isdir(_ctx_wd)) else ""
     if _inplace_dir:
         work_dir = source_dir = _inplace_dir
+    # 车道 B:工作区对话型模板走原生 claude-code(见 _execute_agent native 分支)。
+    _native_ws = (agent.get("agent_template") or "") in _WORKSPACE_CONV_TEMPLATES
 
     # 注入顺序(MEMORY_DESIGN §2.2):本会话前情 → 相关历史(L3) → KB → 用户问题
     enriched_input = req.input
@@ -659,6 +661,7 @@ async def run_agent(
                 kb_tools=_make_platform_kb_tools(
                     db, kb_ids, tenant.tenant_id, kb_search_mode),
                 inplace_dir=_inplace_dir,
+                native_workspace=_native_ws,
             ),
         )
         duration_ms = int((time.time() - t0) * 1000)
@@ -777,6 +780,7 @@ async def run_agent_stream(
     _inplace_dir = _ctx_wd if (_ctx_wd and _os.path.isdir(_ctx_wd)) else ""
     if _inplace_dir:
         work_dir = source_dir = _inplace_dir
+    _native_ws = (agent.get("agent_template") or "") in _WORKSPACE_CONV_TEMPLATES
 
     # 会话记忆 P1:thread 解析(run 记录在下方流式分支创建,这里先拿 id)
     from agentpaas.engine import thread_memory as _tm
@@ -995,6 +999,7 @@ async def run_agent_stream(
                     kb_tools=_make_platform_kb_tools(
                         db, kb_ids, tenant.tenant_id, kb_search_mode),
                     inplace_dir=_inplace_dir,
+                    native_workspace=_native_ws,
                 )
                 duration_ms = int((time.time() - t0) * 1000)
                 workspace_path = trace_info.get("workspace_path", "")
@@ -1962,11 +1967,91 @@ def _compile_agent(config: dict):
         os.unlink(tmp_path)
 
 
+# 原生 claude-code 模式的 system 追加(纪律+安全,不含 react JSON 协议——native
+# 用自己的结构化工具,讲"输出 JSON"反而会让它吐文本 JSON 而不调真工具)。
+_NATIVE_SYSTEM_APPEND = (
+    "你是在用户指定**工作文件夹**(即你的当前目录)里干活的结对助手。"
+    "所有读、写、改、跑命令都在这个文件夹内就地完成,直接用你的原生工具"
+    "(Read/Write/Edit/Bash/Glob/Grep)。\n"
+    "- 用户提到的文件/目录(如 04_paper)先用 ls/glob 在工作文件夹里实际找到再动手;"
+    "找不到就如实报告并询问正确位置,**绝不凭记忆编造内容或伪造数据**。\n"
+    "- 危险/不可逆命令(rm -rf、git reset --hard、强制 push、覆盖重要文件)先说明影响再做。\n"
+    "- 完成任务要真正把产物写到工作文件夹里,不要只在回答里描述。"
+)
+
+
+def _run_native_workspace(*, config: dict, input_text: str, inplace_dir: str,
+                          workspace_path: str, on_step=None, run_id: str = ""):
+    """车道 B:原生 claude-code 执行(workspace.assistant)。返回 (result, trace_info)。"""
+    import os as _os
+    from lambdagent.providers.claude_code_native import run_native
+
+    model_cfg = (config.get("model") or {})
+    model = model_cfg.get("name") or "sonnet"
+    extra = model_cfg.get("extra") or {}
+
+    # native 事件 → 平台 SSE 形状(前端复用现有 think_chunk/tool_call/tool_result)。
+    def _native_on_event(ev: dict):
+        if not on_step:
+            return
+        et = ev.get("event")
+        if et == "think_chunk":
+            on_step({"event": "think_chunk", "data": {"content": ev.get("text", "")}})
+        elif et == "tool_call":
+            on_step({"event": "tool_call",
+                     "data": {"tool": ev.get("tool", ""),
+                              "content": str(ev.get("input", ""))[:1000]}})
+        elif et == "tool_result":
+            on_step({"event": "tool_result", "data": {"content": ev.get("output", "")}})
+
+    cancel = None
+    if run_id:
+        try:
+            from agentpaas.engine.cancellation import get_cancel_token
+            cancel = get_cancel_token(run_id)
+        except Exception:
+            cancel = None
+
+    res = run_native(
+        input_text, cwd=_os.path.abspath(inplace_dir), model=model,
+        system_append=_NATIVE_SYSTEM_APPEND, on_event=_native_on_event,
+        config_extra=extra, cancel=cancel,
+    )
+
+    trace = [{"term_name": "claude-code.native", "term_id": "native",
+              "input": str(t.get("input", ""))[:500],
+              "output": "", "duration_ms": 0, "model": model,
+              "tokens_used": 0, "tool": t.get("tool", "")} for t in res.trace]
+    import json as _json
+    trace_info = {
+        "steps": res.num_turns or len(res.trace),
+        "input_tokens": res.input_tokens,
+        "output_tokens": res.output_tokens,
+        "cache_read_tokens": res.cache_read_tokens,
+        "cache_creation_tokens": res.cache_creation_tokens,
+        "cost_usd": round(res.cost_usd, 6),
+        "trace_json": _json.dumps(trace, ensure_ascii=False),
+        "workspace_path": workspace_path,
+        "provider_error": None,
+        "native_session_id": res.session_id,
+    }
+    if workspace_path:
+        try:
+            save_run_artifacts(
+                workspace_path, str(res.text), trace, 0,
+                res.input_tokens, res.output_tokens, trace_info["steps"],
+            )
+        except Exception as _se:
+            logger.warning("native save_run_artifacts failed: %s", _se)
+    return res.text, trace_info
+
+
 def _execute_agent(config: dict, input_text: str, on_step=None,
                    agent_dir: str = "", run_id: str = "",
                    continue_workspace: str = "", work_dir: str = "",
                    source_dir: str = "", run_dir: str = "",
-                   kb_tools: dict = None, inplace_dir: str = ""):
+                   kb_tools: dict = None, inplace_dir: str = "",
+                   native_workspace: bool = False):
     """Execute agent via lambdagent. Returns (result, trace_info).
 
     3-directory system
@@ -2168,6 +2253,16 @@ def _execute_agent(config: dict, input_text: str, on_step=None,
         if kb_tools:
             overrides["tools"] = {**(overrides.get("tools") or {}), **kb_tools}
 
+        # ── 车道 B:原生 claude-code(workspace.assistant 专用)──
+        # 见 docs/NATIVE_CLAUDE_CODE_DESIGN.md。让 claude-code 当 agent 本身,在
+        # 工作文件夹里用 native 工具干活,而不是套进 react-over-text 封装(那套实测
+        # 工具调用 0 执行、丢上下文、空转)。仅工作区模板 + 有效文件夹时启用。
+        if native_workspace and inplace_dir and os.path.isdir(inplace_dir):
+            return _run_native_workspace(
+                config=config, input_text=input_text, inplace_dir=inplace_dir,
+                workspace_path=workspace_path, on_step=on_step, run_id=run_id,
+            )
+
         term = from_config(config_path, **overrides)
         ctx = Context(workspace_path=workspace_path, run_id=run_id)
 

+ 135 - 0
docs/NATIVE_CLAUDE_CODE_DESIGN.md

@@ -0,0 +1,135 @@
+# 原生 Claude-Code 模式设计(workspace.assistant 专用)
+
+状态:设计 → 实现中
+日期:2026-06-15
+关联记忆:claude-code 大会话 resume 卡死、MCP 隔离、文件工具 CWD、目录守卫、401。
+
+## 1. 动机(一句话)
+
+`workspace.assistant`(选文件夹自由对话)现在被套进 **react-over-text** 封装:
+`claude -p --tools ""` 把 claude-code 阉割成纯文本生成器,平台再用正则从文本里抠
+`{"tool":...}` 自己执行、自己管 session。实测这套**不如直接调一次 claude-code**:
+
+- 工具调用是模型当散文手写的 JSON → 格式飘(`input` 裸串/`path` vs `file_path`)→
+  平台解析失败 → **76 个调用 0 执行 0 落盘**(run_541390a1ea49)。
+- 我们自己的 session 轮换/熔断在 API 高延迟下疯狂触发(40 分钟 14 个 session)→
+  每次重注入只带原始任务、丢掉已读内容 → agent 永远从零开始、空转到步数耗尽。
+- claude-code 的强项(结构化 tool_use、上下文管理、文件编辑、持久会话)**全被关掉**。
+
+**根因:用错模具。** react-over-text 是为**多智能体编排**(orchestrator 必须把活
+派给子智能体,故必须禁 native 工具防它自己偷干——run_4e07736d7f60 伪造数据教训)
+设计的。但 `workspace.assistant` 是**单体 do-everything** 助手,不是 orchestrator,
+套进去只有成本没有收益。
+
+## 2. 目标
+
+为「工作区对话型」agent 开**第二条执行车道**:让 claude-code **当 agent 本身**——
+在所选文件夹里用 native 工具干活,平台只负责选目录、起进程、流式透传、沙箱与落盘。
+**orchestrator / 科研评审 / pipeline 保持现有 react 车道不变。**
+
+## 3. 架构:两条执行车道
+
+```
+run_agent(_stream)
+   ├─ agent_template ∈ {workspace.assistant}  且  inplace_dir 有效
+   │     → 车道 B:原生 claude-code(本设计)
+   └─ 否则
+         → 车道 A:现有 react-over-text(不动)
+```
+
+判定点已就绪:`_require_workspace_dir`(强制绑文件夹)+ `inplace_dir`(=所选文件夹)。
+新增布尔 `native_workspace`,在 `_execute_agent` 内分流。
+
+## 4. 原生 runner 规格(新文件 `providers/claude_code_native.py`)
+
+`run_native(prompt, *, cwd, model, system_append, session_id, on_event, config) -> NativeResult`
+
+### 4.1 命令
+```
+claude -p <prompt>
+  --output-format stream-json --verbose      # 实时事件流
+  --model <model>
+  --append-system-prompt <平台 system 片段>   # 注入工作目录纪律/安全铁律
+  --add-dir <cwd>                            # 文件操作限定在该文件夹
+  --strict-mcp-config                        # 隔离用户 MCP(默认;可配开)
+  --permission-mode bypassPermissions        # -p 非交互必须,否则工具会卡在审批
+  [--resume <session_id>]                    # 多轮续接(同一 thread)
+  (cwd = 工作文件夹)
+```
+- **native 工具开**:不传 `--tools ""`。用 `--allowedTools` 白名单:
+  `Read Write Edit Bash Glob Grep NotebookEdit TodoWrite`(按需加 WebSearch/WebFetch)。
+- **不再有** `--tools ""`、react JSON 协议、ConversationLam、session 轮换/熔断、
+  `file_tools._resolve`。claude-code 用它自己的 native 工具,cwd 已是工作文件夹。
+
+### 4.2 流式事件解析(stream-json 每行一个 JSON)
+
+| claude stream-json 事件 | 平台 on_event(沿用现有 SSE 类型,前端零改动) |
+|---|---|
+| `system/init` | 记录 `session_id` |
+| `assistant` content `text` | `think_chunk`(增量思考) |
+| `assistant` content `tool_use` | `tool_call`(name + input) |
+| `user` content `tool_result` | `tool_result`(output) |
+| `result/success` | `answer` + `done`(result 文本、usage、cost、session_id) |
+| `result` `is_error`/401 | 走 `_is_auth_error` → `_AUTH_HINT`;其余 → 失败 |
+
+### 4.3 返回 / 落盘
+`NativeResult` → 现有 `trace_info` 结构:`input_tokens/output_tokens/cost_usd/
+cache_*_tokens/steps/trace_json/workspace_path/session_id`。直接喂回 run 记录,
+前端 trace/cost 面板不变。
+
+### 4.4 多轮(thread)会话续接
+claude-code 自管上下文,**无需我们轮换**。需要把上一轮 `session_id` 持久化、下一轮
+`--resume`。MVP:sidecar 文件 `<agent_run_base>/.native_sessions.json`
+`{thread_id: session_id}`(不污染用户文件夹、不动 DB schema)。resume 失败(session
+丢失)→ 退化成新 first-turn(claude 自己重建上下文)。
+
+### 4.5 超时/容错
+- 首字节超时(沿用 60s 思路)+ 硬超时(config.timeout)。
+- 401 → `_AUTH_HINT`(已实现,复用)。
+- 卡死直接失败(不再轮换;native 单进程长跑由 claude-code 自己管上下文,不会像
+  resume 那样越长越卡)。
+
+## 5. 沙箱与安全
+
+- **文件**:`cwd=工作文件夹` + 仅 `--add-dir <cwd>`(不加别的目录)→ claude-code 的
+  Read/Write/Edit 限定在该文件夹。
+- **Bash**:native Bash 不经平台 guard。MVP 接受此权衡(cwd 限定 + `bypassPermissions`),
+  与现状(react 车道 Bash 也只靠 CWD)一致。**P1 硬化**:claude-code settings
+  `permissions.deny` 或 PreToolUse hook 拦危险命令/越界写(留迭代)。
+- **MCP**:默认 `--strict-mcp-config` 隔离(避免 Gmail/Calendar 干扰);
+  `config.extra["native_allow_mcp"]=true` 可放开。
+
+## 6. 保留 / 丢弃(仅车道 B)
+
+| 丢弃 | 保留/复用 |
+|---|---|
+| `--tools ""`、react JSON 协议 | `--strict-mcp-config`(MCP 隔离) |
+| ConversationLam / compiler react loop | `_is_auth_error` / `_AUTH_HINT`(401) |
+| session 轮换 / 连卡熔断 | `_require_workspace_dir`(强制绑目录) |
+| `file_tools._resolve` / 平台文件工具 | on_step→SSE 事件管道(前端不变) |
+| obs 截断、max_input_chars | run 记录 / trace_info / cost 落盘 |
+
+## 7. 向后兼容
+
+- 仅 `agent_template ∈ {workspace.assistant}` 走车道 B;其余 agent **零影响**。
+- 可加 `config.extra["execution_mode"]="react"` 强制退回车道 A(兜底开关)。
+- workspace.assistant.yml 标注 `executionMode: native`(声明意图,便于排查)。
+
+## 8. 测试
+
+- `TestNativeStreamParse`:喂一段录制的 stream-json,断言事件映射正确、result/usage 抽取对。
+- `TestNativeAuthError`:result is_error 401 → 抛 `_AUTH_HINT`。
+- `TestNativeRouting`:workspace.assistant + 有效 dir → native 分支;其他模板 → react。
+- live(skip 默认):真起一次 `claude -p` 在临时目录写一个文件,验证落盘。
+
+## 9. 开放决策(已选默认,可改)
+
+1. **权限模式**:`bypassPermissions`(-p 必须非交互)— 选定。
+2. **MCP**:默认隔离 — 选定。
+3. **工具白名单**:`Read Write Edit Bash Glob Grep NotebookEdit TodoWrite` — 选定,可配。
+4. **session 持久化**:sidecar JSON(MVP)→ 后续可升 DB 列。
+
+## 10. 实施顺序
+
+P0(本次):native runner + stream 解析 + agents.py 分流 + 单测 + workspace.yml 标注。
+P1(后续):Bash 硬沙箱(deny/hook)、session 持久化升 DB、native_allow_mcp 配置面。

+ 250 - 0
lambdagent/src/lambdagent/providers/claude_code_native.py

@@ -0,0 +1,250 @@
+"""
+lambdagent.providers.claude_code_native — 原生 Claude-Code 执行车道
+
+工作区对话型 agent(workspace.assistant)专用:让 claude-code **当 agent 本身**
+在所选文件夹里用 native 工具干活,而不是把它阉割成纯文本生成器套进 react-over-text
+封装。见 docs/NATIVE_CLAUDE_CODE_DESIGN.md。
+
+与 claude_code_provider(react 车道)的区别:
+  · react 车道:`claude -p --tools ""` → 纯文本 → 平台正则抠 {"tool"} 自己执行。
+  · native 车道(本模块):`claude -p --output-format stream-json` + native 工具开
+    → claude-code 自己结构化执行工具、自管上下文 → 平台只解析事件流 + 落盘。
+"""
+from __future__ import annotations
+
+import json
+import os
+import select
+import subprocess
+import time
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List, Optional
+
+from lambdagent.providers.base import ProviderError
+from lambdagent.providers.claude_code_provider import (
+    _find_working_claude, _is_auth_error, _AUTH_HINT, logger,
+)
+
+# 默认工具白名单:单体助手该有的全套(读/写/改/跑/搜/笔记/待办)。可经 config.extra 覆盖。
+_DEFAULT_ALLOWED_TOOLS = [
+    "Read", "Write", "Edit", "Bash", "Glob", "Grep", "NotebookEdit", "TodoWrite",
+]
+_DEFAULT_FIRST_BYTE_TIMEOUT_S = 90.0   # 原生长跑首个事件可能稍慢(要先想)
+_DEFAULT_IDLE_TIMEOUT_S = 180.0        # 工具间隔(跑实验/编译)可能较长
+_DEFAULT_HARD_TIMEOUT_S = 1800.0       # 30 分钟硬顶
+
+
+@dataclass
+class NativeResult:
+    text: str = ""
+    session_id: Optional[str] = None
+    input_tokens: int = 0
+    output_tokens: int = 0
+    cache_read_tokens: int = 0
+    cache_creation_tokens: int = 0
+    cost_usd: float = 0.0
+    num_turns: int = 0
+    is_error: bool = False
+    trace: List[dict] = field(default_factory=list)
+
+
+def build_cmd(claude_bin: str, prompt: str, *, cwd: str, model: str,
+              system_append: str = "", session_id: Optional[str] = None,
+              allowed_tools: Optional[List[str]] = None,
+              strict_mcp: bool = True) -> List[str]:
+    """构造 native claude-code 命令。cwd 由调用方在 spawn 时设置。"""
+    cmd = [
+        claude_bin, "-p", prompt,
+        "--output-format", "stream-json", "--verbose",
+        "--model", model,
+        # native 工具限定在工作文件夹(claude-code 的文件操作 root)。
+        "--add-dir", cwd,
+        # -p 非交互:必须 bypass,否则工具卡在权限审批等不到回应。
+        "--permission-mode", "bypassPermissions",
+        "--allowedTools", *(allowed_tools or _DEFAULT_ALLOWED_TOOLS),
+    ]
+    if strict_mcp:
+        cmd.append("--strict-mcp-config")  # 隔离用户 MCP(Gmail/Calendar 等)
+    if system_append:
+        cmd += ["--append-system-prompt", system_append]
+    if session_id:
+        cmd += ["--resume", session_id]
+    return cmd
+
+
+def _emit(on_event, etype: str, payload: dict):
+    if on_event is not None:
+        try:
+            on_event({"event": etype, **payload})
+        except Exception as e:  # 回调异常不能拖垮主流程
+            logger.warning("native on_event(%s) failed: %s", etype, e)
+
+
+def _handle_event(obj: dict, on_event, result: NativeResult):
+    """把一条 claude stream-json 事件映射成平台 SSE 事件 + 累积结果。"""
+    t = obj.get("type")
+    if t == "system" and obj.get("subtype") == "init":
+        result.session_id = obj.get("session_id") or result.session_id
+        return
+    if t == "assistant":
+        for c in (obj.get("message", {}) or {}).get("content", []) or []:
+            if not isinstance(c, dict):
+                continue
+            if c.get("type") == "text" and c.get("text"):
+                _emit(on_event, "think_chunk", {"text": c["text"]})
+            elif c.get("type") == "tool_use":
+                _emit(on_event, "tool_call",
+                      {"tool": c.get("name", ""), "input": c.get("input", {})})
+                result.trace.append({"tool": c.get("name", ""),
+                                     "input": str(c.get("input", ""))[:300]})
+        return
+    if t == "user":
+        for c in (obj.get("message", {}) or {}).get("content", []) or []:
+            if isinstance(c, dict) and c.get("type") == "tool_result":
+                out = c.get("content", "")
+                if isinstance(out, list):
+                    out = " ".join(x.get("text", "") for x in out if isinstance(x, dict))
+                _emit(on_event, "tool_result", {"output": str(out)[:2000]})
+        return
+    if t == "result":
+        result.is_error = bool(obj.get("is_error"))
+        result.text = str(obj.get("result", "") or "")
+        result.session_id = obj.get("session_id") or result.session_id
+        result.num_turns = obj.get("num_turns", 0) or 0
+        result.cost_usd = float(obj.get("total_cost_usd", 0) or 0)
+        u = obj.get("usage", {}) or {}
+        result.input_tokens = u.get("input_tokens", 0) or 0
+        result.output_tokens = u.get("output_tokens", 0) or 0
+        result.cache_read_tokens = u.get("cache_read_input_tokens", 0) or 0
+        result.cache_creation_tokens = u.get("cache_creation_input_tokens", 0) or 0
+
+
+def run_native(prompt: str, *, cwd: str, model: str = "sonnet",
+               system_append: str = "", session_id: Optional[str] = None,
+               on_event: Optional[Callable[[dict], None]] = None,
+               config_extra: Optional[dict] = None,
+               cancel=None) -> NativeResult:
+    """在 cwd 里原生跑一次 claude-code,流式回调事件,返回最终结果。
+
+    auth(401)→ 抛 ProviderError(_AUTH_HINT)。卡死/超时 → 抛 ProviderError(retryable)。
+    """
+    extra = config_extra or {}
+    claude_bin = _find_working_claude(extra.get("claude_bin", "claude")) or "claude"
+    allowed = extra.get("native_allowed_tools") or _DEFAULT_ALLOWED_TOOLS
+    strict_mcp = not bool(extra.get("native_allow_mcp", False))
+    cmd = build_cmd(claude_bin, prompt, cwd=cwd, model=model,
+                    system_append=system_append, session_id=session_id,
+                    allowed_tools=allowed, strict_mcp=strict_mcp)
+
+    first_byte_timeout = float(extra.get("native_first_byte_timeout", _DEFAULT_FIRST_BYTE_TIMEOUT_S))
+    idle_timeout = float(extra.get("native_idle_timeout", _DEFAULT_IDLE_TIMEOUT_S))
+    hard_timeout = float(extra.get("native_hard_timeout", _DEFAULT_HARD_TIMEOUT_S))
+
+    logger.info("claude-code NATIVE spawn: cwd=%s model=%s resume=%s tools=%s strict_mcp=%s",
+                cwd, model, bool(session_id), ",".join(allowed), strict_mcp)
+
+    result = NativeResult()
+    t0 = time.time()
+    proc = subprocess.Popen(
+        cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+        bufsize=0,  # 二进制非阻塞读,不用行缓冲(bufsize=1 在 binary 模式会告警)
+    )
+    buf = b""
+    last_byte_at: Optional[float] = None
+    err_chunks: List[bytes] = []
+    saw_result = False
+
+    def _process_line(line: str):
+        nonlocal saw_result
+        line = line.strip()
+        if not line:
+            return
+        try:
+            obj = json.loads(line)
+        except json.JSONDecodeError:
+            return
+        _handle_event(obj, on_event, result)
+        if obj.get("type") == "result":
+            saw_result = True
+
+    try:
+        import fcntl
+        for f in (proc.stdout, proc.stderr):
+            fl = fcntl.fcntl(f, fcntl.F_GETFL)
+            fcntl.fcntl(f, fcntl.F_SETFL, fl | os.O_NONBLOCK)
+
+        while proc.poll() is None:
+            now = time.time()
+            if cancel is not None and cancel.is_cancelled():
+                proc.kill()
+                raise cancel.CancelledRun()
+            if now - t0 > hard_timeout:
+                proc.kill()
+                raise ProviderError(
+                    f"claude-code native hard-timeout after {now-t0:.0f}s", "claude-code",
+                    retryable=True)
+            if last_byte_at is None and now - t0 > first_byte_timeout:
+                proc.kill()
+                raise ProviderError(
+                    f"claude-code native first-byte-timeout after {now-t0:.0f}s", "claude-code",
+                    retryable=True)
+            if last_byte_at is not None and now - last_byte_at > idle_timeout:
+                proc.kill()
+                raise ProviderError(
+                    f"claude-code native idle-timeout after {now-last_byte_at:.0f}s", "claude-code",
+                    retryable=True)
+
+            ready, _, _ = select.select([proc.stdout, proc.stderr], [], [], 0.5)
+            for fobj in ready:
+                try:
+                    chunk = fobj.read()
+                except (BlockingIOError, OSError):
+                    chunk = b""
+                if not chunk:
+                    continue
+                last_byte_at = now
+                if fobj is proc.stderr:
+                    err_chunks.append(chunk)
+                    continue
+                buf += chunk
+                while b"\n" in buf:
+                    line, buf = buf.split(b"\n", 1)
+                    _process_line(line.decode(errors="replace"))
+
+        # drain
+        try:
+            rem_out, rem_err = proc.communicate(timeout=5)
+        except subprocess.TimeoutExpired:
+            rem_out, rem_err = b"", b""
+        if rem_err:
+            err_chunks.append(rem_err)
+        buf += rem_out or b""
+        for line in buf.split(b"\n"):
+            _process_line(line.decode(errors="replace"))
+    finally:
+        if proc.poll() is None:
+            try:
+                proc.kill()
+            except Exception:
+                pass
+
+    stderr_txt = b"".join(err_chunks).decode(errors="replace")
+
+    # 认证失败:result is_error + 401,或 stderr 含 auth 标记。
+    if (result.is_error and _is_auth_error(result.text)) or _is_auth_error(stderr_txt):
+        raise ProviderError(_AUTH_HINT, "claude-code", retryable=False)
+    if result.is_error:
+        raise ProviderError(
+            f"claude-code native error: {result.text[:300] or stderr_txt[:300] or '(no detail)'}",
+            "claude-code", retryable=False)
+    if not saw_result:
+        # 进程结束却没拿到 result 事件 = 异常退出
+        raise ProviderError(
+            f"claude-code native produced no result (exit {proc.returncode}). "
+            f"stderr: {stderr_txt[:300] or '(empty)'}",
+            "claude-code", retryable=True)
+
+    logger.info("claude-code NATIVE ok: %.1fs turns=%d in=%d out=%d cost=$%.4f session=%s",
+                time.time()-t0, result.num_turns, result.input_tokens,
+                result.output_tokens, result.cost_usd, result.session_id)
+    return result

+ 95 - 0
lambdagent/tests/test_native.py

@@ -0,0 +1,95 @@
+"""原生 claude-code 执行车道(claude_code_native)单测。
+
+覆盖:命令构造、stream-json 事件 → 平台事件映射、usage/session 抽取、401 识别。
+"""
+from __future__ import annotations
+
+import pytest
+
+from lambdagent.providers import claude_code_native as ncc
+from lambdagent.providers.claude_code_native import (
+    build_cmd, _handle_event, NativeResult,
+)
+
+
+class TestBuildCmd:
+    def test_native_enables_tools_no_react_neuter(self):
+        cmd = build_cmd("claude", "task", cwd="/w", model="sonnet")
+        assert "--tools" not in cmd                 # 不阉割原生工具
+        assert "--allowedTools" in cmd
+        for t in ("Read", "Write", "Edit", "Bash", "Glob", "Grep"):
+            assert t in cmd
+        assert cmd[cmd.index("--add-dir") + 1] == "/w"
+        assert "--strict-mcp-config" in cmd          # 默认隔离 MCP
+        assert "bypassPermissions" in cmd
+        assert "stream-json" in cmd
+
+    def test_resume_adds_flag(self):
+        cmd = build_cmd("claude", "t", cwd="/w", model="sonnet", session_id="s-1")
+        assert "--resume" in cmd and "s-1" in cmd
+
+    def test_no_resume_when_none(self):
+        cmd = build_cmd("claude", "t", cwd="/w", model="sonnet", session_id=None)
+        assert "--resume" not in cmd
+
+    def test_allow_mcp_drops_strict(self):
+        cmd = build_cmd("claude", "t", cwd="/w", model="sonnet", strict_mcp=False)
+        assert "--strict-mcp-config" not in cmd
+
+
+class TestHandleEvent:
+    def _collect(self, events):
+        out = []
+        res = NativeResult()
+        for e in events:
+            _handle_event(e, lambda ev: out.append(ev), res)
+        return out, res
+
+    def test_session_init_captured(self):
+        _, res = self._collect([{"type": "system", "subtype": "init", "session_id": "sess-X"}])
+        assert res.session_id == "sess-X"
+
+    def test_text_and_tool_use_mapped(self):
+        events = [
+            {"type": "assistant", "message": {"content": [
+                {"type": "text", "text": "thinking…"},
+                {"type": "tool_use", "name": "Write",
+                 "input": {"file_path": "/w/out.md", "content": "x"}},
+            ]}},
+        ]
+        out, res = self._collect(events)
+        kinds = [e["event"] for e in out]
+        assert "think_chunk" in kinds and "tool_call" in kinds
+        tc = next(e for e in out if e["event"] == "tool_call")
+        assert tc["tool"] == "Write"
+        assert any(t["tool"] == "Write" for t in res.trace)
+
+    def test_tool_result_mapped(self):
+        events = [{"type": "user", "message": {"content": [
+            {"type": "tool_result", "content": "wrote 1 file"}]}}]
+        out, _ = self._collect(events)
+        assert out and out[0]["event"] == "tool_result"
+        assert "wrote 1 file" in out[0]["output"]
+
+    def test_result_extracts_usage_cost_session(self):
+        ev = {"type": "result", "subtype": "success", "is_error": False,
+              "result": "done, wrote paper.md", "session_id": "sess-Y",
+              "num_turns": 12, "total_cost_usd": 0.34,
+              "usage": {"input_tokens": 1000, "output_tokens": 500,
+                        "cache_read_input_tokens": 200,
+                        "cache_creation_input_tokens": 50}}
+        _, res = self._collect([ev])
+        assert res.text == "done, wrote paper.md"
+        assert res.session_id == "sess-Y"
+        assert res.num_turns == 12
+        assert res.cost_usd == pytest.approx(0.34)
+        assert res.input_tokens == 1000 and res.output_tokens == 500
+        assert res.cache_read_tokens == 200 and res.cache_creation_tokens == 50
+        assert res.is_error is False
+
+    def test_result_is_error_401_detectable(self):
+        ev = {"type": "result", "is_error": True,
+              "result": "Failed to authenticate. API Error: 401 Invalid authentication credentials"}
+        _, res = self._collect([ev])
+        assert res.is_error is True
+        assert ncc._is_auth_error(res.text)   # run_native 会据此抛 _AUTH_HINT