Spaces:
Sleeping
Sleeping
File size: 8,293 Bytes
9f2df60 | 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 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | """
Incremental extraction of the user-visible answer from a streamed model output.
Why this exists (latency fix / streaming):
The lead agent uses provider-native structured output, so the final model call
streams JSON tokens like:
{"response": "Der EMBA HSG kostet ...", "additional_details": ...}
Streaming that raw JSON to the user is unacceptable. This parser consumes the
token stream incrementally and emits ONLY the decoded value of the "response"
field as it grows. If the stream turns out not to be JSON (plain text models,
fallback models without structured output), it passes text through unchanged.
"""
class ResponseFieldStreamParser:
"""Feed streamed text chunks, receive displayable deltas back."""
_ESCAPES = {
'n': '\n', 't': '\t', 'r': '\r', 'b': '\b', 'f': '\f',
'"': '"', '\\': '\\', '/': '/',
}
_FIELD_KEY = '"response"'
def __init__(self, allow_plain_text: bool = True) -> None:
"""
Args:
allow_plain_text: When False, suppress all non-JSON/preamble text
and emit only a decoded top-level ``response`` field. Use this
for structured-output agent streams where tool calls, tool
arguments, or provider reasoning must never be shown to users.
"""
self._buffer = ""
self._mode: str | None = None # None (undecided) | 'json' | 'plain'
self._allow_plain_text = allow_plain_text
self._emitted_plain = 0
self._emitted_value = 0
self.field_complete = False
def feed(self, text: str) -> str:
"""Add a stream chunk; returns the new displayable delta ('' if none)."""
if not text:
return ""
self._buffer += text
if self._mode is None:
stripped = self._buffer.lstrip()
if not stripped:
return ""
self._mode = 'json' if stripped[0] == '{' or not self._allow_plain_text else 'plain'
if self._mode == 'plain':
delta = self._buffer[self._emitted_plain:]
self._emitted_plain = len(self._buffer)
return delta
if self.field_complete:
return ""
value, closed = self._extract_response_value()
if value is None:
return ""
delta = value[self._emitted_value:]
self._emitted_value = len(value)
if closed:
self.field_complete = True
return delta
# ------------------------------------------------------------------ #
def _extract_response_value(self) -> tuple[str | None, bool]:
"""
Scan the buffer for the "response" field and decode its (possibly
still growing) string value. Returns (value_so_far, is_closed).
(None, False) when the field has not started yet.
"""
buf = self._buffer
key_pos = self._find_top_level_response_key(buf)
if key_pos == -1:
return None, False
colon = buf.find(':', key_pos + len(self._FIELD_KEY))
if colon == -1:
return None, False
i = colon + 1
while i < len(buf) and buf[i] in ' \t\r\n':
i += 1
if i >= len(buf) or buf[i] != '"':
# Value did not start (yet) or is not a string — emit nothing.
return None, False
i += 1
out: list[str] = []
while i < len(buf):
ch = buf[i]
if ch == '\\':
if i + 1 >= len(buf):
break # incomplete escape — wait for more input
nxt = buf[i + 1]
if nxt == 'u':
if i + 6 > len(buf):
break # incomplete \uXXXX — wait for more input
try:
out.append(chr(int(buf[i + 2:i + 6], 16)))
except ValueError:
out.append(buf[i:i + 6])
i += 6
else:
out.append(self._ESCAPES.get(nxt, nxt))
i += 2
elif ch == '"':
return ''.join(out), True
else:
out.append(ch)
i += 1
return ''.join(out), False
def _find_top_level_response_key(self, buf: str) -> int:
"""
Return the position of a real top-level ``"response"`` object key.
A simple ``str.find('"response"')`` is unsafe for streaming agent
output: retrieval/tool arguments can contain the word "response" inside
string values before the final structured answer arrives. Matching that
text makes the UI stream internal query/database content. This scanner
searches candidate JSON objects independently and only accepts keys at
depth 1, outside strings, followed by a colon. Treating each object
independently matters because agent streams can contain plain preamble,
tool-call JSON, database snippets, and then the final structured object;
braces or quotes in the earlier material must not poison the final
answer scan.
"""
start = 0
while True:
obj_start = buf.find('{', start)
if obj_start == -1:
return -1
if not self._looks_like_json_object_start(buf, obj_start):
start = obj_start + 1
continue
key_pos, obj_end, is_valid_candidate = self._scan_object_for_response_key(
buf,
obj_start,
)
if key_pos != -1:
return key_pos
if obj_end is not None:
start = obj_end + 1
continue
if is_valid_candidate:
return -1
start = obj_start + 1
@staticmethod
def _looks_like_json_object_start(buf: str, obj_start: int) -> bool:
i = obj_start + 1
while i < len(buf) and buf[i] in ' \t\r\n':
i += 1
return i >= len(buf) or buf[i] in '"}'
def _scan_object_for_response_key(
self,
buf: str,
obj_start: int,
) -> tuple[int, int | None, bool]:
"""
Scan one candidate JSON object.
Returns:
(key_position, object_end, is_valid_candidate)
key_position is -1 when no top-level response key is present.
object_end is None when the candidate object is still incomplete.
is_valid_candidate is False for plain-text brace fragments that are
not worth waiting on.
"""
depth = 0
in_string = False
escaped = False
expecting_key = False
saw_key_or_end = False
i = obj_start
while i < len(buf):
ch = buf[i]
if in_string:
if escaped:
escaped = False
elif ch == '\\':
escaped = True
elif ch == '"':
in_string = False
i += 1
continue
if ch == '"':
if depth == 1 and expecting_key and buf.startswith(self._FIELD_KEY, i):
end = i + len(self._FIELD_KEY)
j = end
while j < len(buf) and buf[j] in ' \t\r\n':
j += 1
if j < len(buf) and buf[j] == ':':
return i, None, True
saw_key_or_end = True
in_string = True
i += 1
continue
if ch == '{':
depth += 1
expecting_key = depth == 1
elif ch == '}':
depth = max(0, depth - 1)
if depth == 0:
return -1, i, True
expecting_key = False
elif ch == '[':
depth += 1
expecting_key = False
elif ch == ']':
depth = max(0, depth - 1)
expecting_key = False
elif depth == 1 and ch == ',':
expecting_key = True
elif depth == 1 and ch == ':':
expecting_key = False
i += 1
return -1, None, saw_key_or_end or expecting_key
|