|
|
@@ -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
|