Преглед на файлове

feat: agent67 streaming UI — real-time output + step progress

ClaudeLam v2:
  - Stream stdout via Popen (char-by-char, real-time display)
  - Shows "🐑 " prefix then streams response as it arrives
  - Fallback to blocking mode via stream=False

PersonalAssistant.chat() v2:
  - Shows step counter: [步骤 1/15], [步骤 2/15]...
  - Spinner for non-streaming mode: ⏳ 思考中...
  - Tool call display: 🔧 tool name + input preview + result preview
  - Thinking display: 💭 thought content
  - Completion display: ✅ 任务完成

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kenny67nju преди 5 месеца
родител
ревизия
6239ca6a4f
променени са 3 файла, в които са добавени 142 реда и са изтрити 39 реда
  1. 48 8
      agentexample/agent67/core/assistant.py
  2. 90 30
      agentexample/agent67/core/claude_lam.py
  3. 4 1
      agentexample/agent67/run.py

+ 48 - 8
agentexample/agent67/core/assistant.py

@@ -229,7 +229,7 @@ class PersonalAssistant:
 
     def chat(self, user_msg: str) -> str:
         """
-        与 lambda 对话。完整 ReAct 循环 (v2)。
+        与 lambda 对话。完整 ReAct 循环 (v2 流式交互)。
         """
         self.conversation_history.append({"role": "用户", "content": user_msg})
 
@@ -237,15 +237,31 @@ class PersonalAssistant:
         max_steps = 15
         final_response = ""
 
+        import time as _time
+
         for step in range(max_steps):
+            # 显示当前步骤状态
+            _step_label = f"[步骤 {step + 1}/{max_steps}]"
+
             # 构建输入
             if step == 0:
                 llm_input = self._build_input(user_msg)
             else:
                 llm_input = self._build_input(user_msg, observations)
 
-            # Brain 思考 (β-规约)
+            # 显示思考状态(如果不是流式模式,显示 spinner)
+            is_streaming = hasattr(self.brain, 'stream') and self.brain.stream
+            if not is_streaming:
+                print(f"  ⏳ {_step_label} 思考中...", end="\r", flush=True)
+
+            # Brain 思考 (β-规约) — 流式模式下 ClaudeLam 会实时输出
+            t0 = _time.time()
             llm_output = self.brain.apply(llm_input, self.ctx)
+            think_ms = (_time.time() - t0) * 1000
+
+            if not is_streaming:
+                # 清除 spinner
+                print(f"  ✅ {_step_label} 思考完成 ({think_ms:.0f}ms)          ")
 
             # 解析并执行 (v2: 支持内置工具 + hooks)
             result, is_done = parse_and_execute(str(llm_output), self.hooks)
@@ -253,6 +269,7 @@ class PersonalAssistant:
             if is_done:
                 text_part = re.sub(r'```json.*?```', '', str(llm_output), flags=re.DOTALL).strip()
                 final_response = text_part + ("\n" + result if result else "")
+                print(f"  ✅ {_step_label} 任务完成")
                 break
 
             if result != str(llm_output):
@@ -261,22 +278,45 @@ class PersonalAssistant:
                 json_matches = re.findall(r'\{[^{}]*(?:"action"|"tool")\s*:.*?\}', str(llm_output))
                 tool_call = json_matches[-1] if json_matches else ""
 
-                # v2: 使用 TerminalUI 显示
                 tool_name = ""
                 try:
                     tool_name = json.loads(tool_call).get("action", json.loads(tool_call).get("tool", ""))
                 except Exception:
                     pass
 
-                if text_part:
-                    print(f"  💭 {text_part[:150]}")
-                self.ui.tool_start(tool_name or "tool", tool_call[:80])
-                self.ui.tool_end(tool_name or "tool", result[:200], 0)
+                # 显示思考内容
+                if text_part and not is_streaming:
+                    print(f"  💭 {text_part[:200]}")
+
+                # 显示工具调用(带进度)
+                t0_tool = _time.time()
+                print(f"  🔧 {_step_label} 调用工具: {tool_name or 'unknown'}")
+
+                # 工具输入预览
+                try:
+                    tool_input_data = json.loads(tool_call).get("input", {})
+                    if isinstance(tool_input_data, dict):
+                        input_preview = json.dumps(tool_input_data, ensure_ascii=False)[:120]
+                    else:
+                        input_preview = str(tool_input_data)[:120]
+                    print(f"     输入: {input_preview}")
+                except Exception:
+                    pass
+
+                tool_ms = (_time.time() - t0_tool) * 1000
+
+                # 工具结果预览
+                result_preview = result[:300].replace("\n", "\n     ")
+                print(f"     结果: {result_preview}")
                 print()
 
                 observations.append(f"[工具调用] {tool_call}\n[执行结果] {result}")
             else:
-                final_response = str(llm_output)
+                # 纯文本回复(流式模式下已经显示过了)
+                if not is_streaming:
+                    final_response = str(llm_output)
+                else:
+                    final_response = str(llm_output)
                 break
         else:
             final_response = f"(达到最大步数 {max_steps})\n最后的观察:\n" + (

+ 90 - 30
agentexample/agent67/core/claude_lam.py

@@ -1,5 +1,5 @@
 """
-agent67.core.claude_lam — 基于 Claude Code CLI 的 Lam 实现
+agent67.core.claude_lam — 基于 Claude Code CLI 的 Lam 实现 (v2 流式输出)
 
 不需要 API Key,直接使用 Claude Code Max Plan。
 
@@ -7,18 +7,16 @@ Lambda 语义不变:
     ClaudeLam("name", "prompt") ≡ λ_D . F_{claude,D}
     调用 = β-规约 = claude -p 解码
 
-用法:
-    brain = ClaudeLam("assistant", "你是一个助手")
-    result = brain("帮我查看文件", ctx)
+v2: 流式输出 — 用户能实时看到 LLM 的回复过程
 """
 from __future__ import annotations
 
-import json
 import subprocess
+import sys
+import threading
 import time
 from typing import Any, Callable, Optional
 
-import sys
 from pathlib import Path
 PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
 sys.path.insert(0, str(PROJECT_ROOT))
@@ -28,10 +26,7 @@ from lambdagent.core import Term, Context
 
 class ClaudeLam(Term):
     """
-    基于 Claude Code CLI 的 Lambda 抽象。
-
-    等价于 Lam,但通过 `claude -p` 调用,
-    使用 Claude Code Max Plan 的额度,无需 API Key。
+    基于 Claude Code CLI 的 Lambda 抽象(流式输出版)。
 
     Lambda: ClaudeLam("name", "prompt") = λx. claude(prompt, x)
     """
@@ -44,6 +39,7 @@ class ClaudeLam(Term):
         max_tokens: int = 4096,
         output_parser: Callable[[str], Any] | None = None,
         claude_bin: str = "claude",
+        stream: bool = True,
     ):
         super().__init__(name)
         self.prompt = prompt
@@ -51,43 +47,111 @@ class ClaudeLam(Term):
         self.max_tokens = max_tokens
         self.output_parser = output_parser or (lambda x: x)
         self.claude_bin = claude_bin
+        self.stream = stream
 
     def apply(self, input: Any, ctx: Context | None = None) -> Any:
         """β-规约: (λ_D x) → claude -p (prompt + x)"""
         ctx = ctx or Context()
         t0 = time.time()
 
-        raw = self._call_claude(str(input))
+        if self.stream:
+            raw = self._call_claude_stream(str(input))
+        else:
+            raw = self._call_claude(str(input))
+
         duration = (time.time() - t0) * 1000
         result = self.output_parser(raw)
-
         ctx.log(self._name, self._trace_id, input, result, duration, f"claude-code/{self.model}")
         return result
 
-    def _call_claude(self, input_text: str) -> str:
+    def _call_claude_stream(self, input_text: str) -> str:
         """
-        通过 claude CLI 执行 β-规约。
-
-        等价于: echo "input" | claude -p --system-prompt "prompt" --output-format text
+        流式调用 claude CLI — 实时显示输出。
+        使用 Popen 逐字符读取 stdout。
         """
-        # 将 system prompt 和用户输入合并,减少 CLI 参数长度
-        # 超长 system prompt 通过 stdin 传入更可靠
         full_input = f"[System Instructions]\n{self.prompt}\n\n[User Input]\n{input_text}"
 
         try:
             cmd = [
                 self.claude_bin,
-                "-p",                          # print mode (non-interactive)
-                "--output-format", "text",     # 纯文本输出
-                "--model", self.model,          # 模型选择
+                "-p",
+                "--output-format", "text",
+                "--model", self.model,
             ]
 
-            result = subprocess.run(
+            proc = subprocess.Popen(
                 cmd,
-                input=full_input,
-                capture_output=True,
+                stdin=subprocess.PIPE,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
                 text=True,
-                timeout=180,  # 3 分钟超时(复杂任务需要更多时间)
+                bufsize=1,
+            )
+
+            # 写入 stdin 并关闭(触发处理)
+            proc.stdin.write(full_input)
+            proc.stdin.close()
+
+            # 流式读取 stdout
+            output_chars = []
+            start = time.time()
+            first_token = True
+
+            while True:
+                char = proc.stdout.read(1)
+                if not char:
+                    break
+                if time.time() - start > 180:
+                    proc.kill()
+                    return "[Claude Code 超时] 请求超过 180 秒"
+
+                output_chars.append(char)
+
+                # 实时显示(淡色,和最终回复区分)
+                if first_token:
+                    sys.stdout.write("  🐑 ")
+                    first_token = False
+                sys.stdout.write(char)
+                sys.stdout.flush()
+
+            # 等待进程结束
+            proc.wait(timeout=5)
+
+            if not first_token:
+                sys.stdout.write("\n")
+                sys.stdout.flush()
+
+            if proc.returncode != 0:
+                stderr = proc.stderr.read().strip()
+                if stderr:
+                    return f"[Claude Code 错误] {stderr[:500]}"
+
+            output = "".join(output_chars).strip()
+            return output if output else "[无输出]"
+
+        except FileNotFoundError:
+            return (
+                "[错误] 找不到 claude 命令。"
+                "请确保已安装 Claude Code: npm install -g @anthropic-ai/claude-code"
+            )
+        except Exception as e:
+            return f"[错误] {e}"
+
+    def _call_claude(self, input_text: str) -> str:
+        """非流式调用(fallback)。"""
+        full_input = f"[System Instructions]\n{self.prompt}\n\n[User Input]\n{input_text}"
+
+        try:
+            cmd = [
+                self.claude_bin,
+                "-p",
+                "--output-format", "text",
+                "--model", self.model,
+            ]
+
+            result = subprocess.run(
+                cmd, input=full_input,
+                capture_output=True, text=True, timeout=180,
             )
 
             if result.returncode != 0:
@@ -102,14 +166,10 @@ class ClaudeLam(Term):
         except subprocess.TimeoutExpired:
             return "[Claude Code 超时] 请求超过 180 秒"
         except FileNotFoundError:
-            return (
-                "[错误] 找不到 claude 命令。"
-                "请确保已安装 Claude Code: npm install -g @anthropic-ai/claude-code"
-            )
+            return "[错误] 找不到 claude 命令。"
         except Exception as e:
             return f"[错误] {e}"
 
     def __rshift__(self, other):
-        """支持 >> 组合"""
         from lambdagent.primitives import Compose
         return Compose(self, other)

+ 4 - 1
agentexample/agent67/run.py

@@ -107,7 +107,10 @@ def main():
 
         print()
         response = assistant.chat(user_input)
-        print(f"🐑 lambda: {response}")
+        # 如果是流式模式,response 已经在 chat() 中逐字显示过
+        # 只有非流式模式才在这里打印
+        if not (hasattr(assistant.brain, 'stream') and assistant.brain.stream):
+            print(f"🐑 lambda: {response}")
         print()