File size: 5,069 Bytes
b2c86fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""
StreamingCallback:拦截 agent 的输出,转成结构化 SSE 事件

后端通过 patch agent 的 print 输出方法捕获步骤事件,并转成 SSE。
"""
import io
import queue
import re
import sys


class StreamingCallback:
    """
    拦截 agent.go() 的 stdout 输出,解析成结构化事件。
    
    Biomni 的 agent 通过 print() 输出中间步骤:
    - "Thought: ..." → thinking 事件
    - "```python\n...\n```" → code 事件  
    - "Observation: ..." → observation 事件
    - 最终答案 → result 事件
    """

    def __init__(self, event_queue: queue.Queue):
        self.q = event_queue
        self._original_stdout = None
        self._buf = io.StringIO()
        self._in_solution = False
        self._solution_lines: list[str] = []

    def attach(self, _agent):
        """patch sys.stdout 来捕获 agent 的 print 输出"""
        self._original_stdout = sys.stdout
        sys.stdout = _InterceptWriter(self._parse_and_emit, self._original_stdout)

    def detach(self, _agent):
        # 兜底:若流结束时仍在 solution 块内,仍然输出 result
        self._flush_solution_if_any()
        if self._original_stdout:
            sys.stdout = self._original_stdout

    def _parse_and_emit(self, text: str):
        """解析 agent 输出文本,转成结构化事件"""
        raw_text = text.rstrip("\n")
        if self._handle_solution_markup(raw_text):
            return

        text = raw_text.strip()
        if not text:
            return

        # 匹配 Biomni A1 的输出格式
        if text.startswith("Thought:") or text.startswith("[Thinking]"):
            self.q.put({"type": "thinking", "content": text})

        elif "```python" in text or "```r" in text or "```bash" in text:
            # 提取代码块
            match = re.search(r"```(\w+)\n(.*?)```", text, re.DOTALL)
            if match:
                lang, code = match.group(1), match.group(2)
                self.q.put({"type": "code", "lang": lang, "content": code})
            else:
                self.q.put({"type": "thinking", "content": text})

        elif text.startswith("Observation:") or text.startswith("[Output]"):
            self.q.put({"type": "observation", "content": text})

        elif text.startswith("Action:") or "tool" in text.lower()[:30]:
            # 尝试提取工具名
            tool_match = re.search(r"(\w+)\(", text)
            tool_name = tool_match.group(1) if tool_match else "tool"
            self.q.put({"type": "tool_use", "tool": tool_name, "content": text})

        elif text.startswith("Final Answer:") or text.startswith("Answer:"):
            self.q.put({"type": "result", "content": text})

        else:
            # 其他输出作为 thinking
            self.q.put({"type": "thinking", "content": text})

    def _handle_solution_markup(self, raw_text: str) -> bool:
        """
        解析 <solution>...</solution> 多行块。
        命中时输出 type=result,并返回 True 表示该行已被消费。
        """
        if self._in_solution:
            if "</solution>" in raw_text:
                before_end, _after_end = raw_text.split("</solution>", 1)
                if before_end:
                    self._solution_lines.append(before_end)
                self._flush_solution_if_any()
                self._in_solution = False
            else:
                self._solution_lines.append(raw_text)
            return True

        if "<solution>" in raw_text:
            _before_start, after_start = raw_text.split("<solution>", 1)
            self._in_solution = True
            self._solution_lines = []

            if "</solution>" in after_start:
                content, _after_end = after_start.split("</solution>", 1)
                if content:
                    self._solution_lines.append(content)
                self._flush_solution_if_any()
                self._in_solution = False
            else:
                if after_start:
                    self._solution_lines.append(after_start)
            return True

        return False

    def _flush_solution_if_any(self):
        content = "\n".join(self._solution_lines).strip()
        self._solution_lines = []
        if content:
            self.q.put({"type": "result", "content": content})


class _InterceptWriter:
    """替换 sys.stdout,拦截写入"""
    def __init__(self, callback, original):
        self._cb = callback
        self._orig = original
        self._line_buf = ""

    def write(self, text):
        self._orig.write(text)  # 同时写到真实 stdout(方便调试)
        self._line_buf += text
        if "\n" in self._line_buf:
            lines = self._line_buf.split("\n")
            for line in lines[:-1]:
                self._cb(line)
            self._line_buf = lines[-1]

    def flush(self):
        if self._line_buf:
            self._cb(self._line_buf)
            self._line_buf = ""
        self._orig.flush()

    def __getattr__(self, name):
        return getattr(self._orig, name)