fast-completions 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/lib/fast/completions/args_streamer.rb +404 -0
- data/lib/fast/completions/protocol.rb +20 -5
- data/lib/fast/completions/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ad51b1c13ffc9236e40151891d9c935967c8ca06b982d43e9871e628900e888
|
|
4
|
+
data.tar.gz: b814642c835590b0399ff287b0ea2631bf2382766f242ba95a9e8857f9d8a8ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bfbd4a381f09305f49fdeb578cbde26cf0599de031efa9af638d88dce48b96c38abbabd52e97071633404cdfa7c52237d05b718389465b25595a333a559b549
|
|
7
|
+
data.tar.gz: d510754ec3b2da6fb8564d51d1dd6925f7382423fc9312a4618af126c7174679a4641b62f6e537a2c78f4c6e10d68a299ff455c100093a4f9a1857b4ee9cace8
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,49 @@
|
|
|
3
3
|
Notable changes to the fast-completions gem. The format is prose-first,
|
|
4
4
|
one section per released version, newest first.
|
|
5
5
|
|
|
6
|
+
## 0.5.0 (2026-10-17)
|
|
7
|
+
|
|
8
|
+
- Tool-args streaming is now an incremental string-state machine
|
|
9
|
+
(`Fast::Completions::ArgsStreamer`, a new stdlib-only class): instead
|
|
10
|
+
of eagerly re-parsing the whole arguments scratch on every delta
|
|
11
|
+
(`JSON.parse` → `repair_json` → re-parse, O(n²) across a stream), each
|
|
12
|
+
tool-call block owns a streamer whose parser state carries ACROSS
|
|
13
|
+
deltas and only the NEW fragment's bytes are walked per delta — O(n)
|
|
14
|
+
total, no per-delta copy of any accumulated buffer, and no exception
|
|
15
|
+
on the streaming path (the machine is total on any fragment sequence;
|
|
16
|
+
malformed input freezes the live view rather than crashing the step).
|
|
17
|
+
This is a deliberate departure from pi, which re-parses per delta
|
|
18
|
+
(openai-completions.ts :652-658).
|
|
19
|
+
- The live view of a tool-call block's `arguments` Hash is now a
|
|
20
|
+
first-class feature (operator ruling, plan ENTRY 1 — “watching a
|
|
21
|
+
file write is extremely nice”): the same Hash object is populated in
|
|
22
|
+
place for the whole stream, nested containers materialize at their
|
|
23
|
+
opening token, and an in-flight string value GROWS live — a consumer
|
|
24
|
+
holding the reference sees it grow — instead of the whole pair
|
|
25
|
+
vanishing until the closing quote. Escape sequences split by a delta boundary are held (the
|
|
26
|
+
0.4.0/0.4.1 crash class becomes a structural non-event); invalid
|
|
27
|
+
escape chars and invalidated `\u` runs contribute their literal text
|
|
28
|
+
(`repair_json` parity, absorbed rather than called); surrogate halves
|
|
29
|
+
never enter the live value, pairs combine; scalars materialize as raw
|
|
30
|
+
text and are replaced by Integer/Float/true/false/null when the token
|
|
31
|
+
terminates (malformed scalars keep their raw text). After the root
|
|
32
|
+
object closes — or the stream becomes unparseable — the live view
|
|
33
|
+
freezes. End states are unchanged and byte-identical to 0.4.1:
|
|
34
|
+
finalize remains the sole authority, parsing the full scratch once
|
|
35
|
+
(`parse_streaming_json`: the JSON.parse fast path when the stream
|
|
36
|
+
completed, the repair walk and `{}` as the last resort otherwise).
|
|
37
|
+
Mid-stream, the two protocol_spec pins that encoded the old
|
|
38
|
+
truncation artifact as `eq({})` are amended to the growing view —
|
|
39
|
+
the ONLY spec changes besides the machine's new unit spec.
|
|
40
|
+
- Bench harness: `compare_refs.rb` now measures a fifth ref — 0.5.0,
|
|
41
|
+
the working tree at HEAD (the lane tip before the integrator's
|
|
42
|
+
landing commit) — beside the four releases, pairs 0.4.1→0.5.0, and
|
|
43
|
+
gains the machine's own pair-scoped floor: tool_args at 60 µs/delta
|
|
44
|
+
(0.4.1 measured 877.4 µs/delta on the frozen 24 KB / 320-fragment
|
|
45
|
+
workload; the machine lands in single-digit µs). BASELINE.md is
|
|
46
|
+
regenerated with the 0.5.0 column. The four bench scripts stay
|
|
47
|
+
byte-frozen — the workload the floors are calibrated to.
|
|
48
|
+
|
|
6
49
|
## 0.4.1 (2026-10-16)
|
|
7
50
|
|
|
8
51
|
- Fix a mid-stream crash in the tool-call live-parse (regression against
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# Incremental tool-args string-state machine (I02, 0.5.0). A DELIBERATE
|
|
6
|
+
# departure from pi: pi re-parses the whole arguments scratch on every
|
|
7
|
+
# delta (openai-completions.ts :652-658) — O(n²) across a stream and
|
|
8
|
+
# the pair-in-flight vanishes until its closing quote. This machine
|
|
9
|
+
# instead carries per-block parser state ACROSS deltas and walks only
|
|
10
|
+
# the NEW fragment's bytes each time (O(n) total, no per-delta copy of
|
|
11
|
+
# any accumulated buffer, no exception on the streaming path), growing
|
|
12
|
+
# the live view (`block[:arguments]`, the root_target below) IN PLACE.
|
|
13
|
+
# The authoritative whole-buffer parse still happens exactly once, at
|
|
14
|
+
# finalize (Protocol.parse_streaming_json — the unchanged pi parity).
|
|
15
|
+
#
|
|
16
|
+
# THE LIVE-VIEW CONTRACT (frozen, Specification L1-L9). L8 totality:
|
|
17
|
+
# this source throws no exceptions and installs none — the machine
|
|
18
|
+
# is total on any fragment sequence: malformed input freezes the
|
|
19
|
+
# live view and never crashes the stream. The frozen interface:
|
|
20
|
+
# ArgsStreamer.new(
|
|
21
|
+
# root_target) — the block's arguments Hash, populated in place (L1);
|
|
22
|
+
# #consume(String) -> self — append to the scratch, walk the new bytes;
|
|
23
|
+
# #scratch -> String — the raw bytes accumulated, for the finalize
|
|
24
|
+
# parse. Everything below the interface is this file's own design.
|
|
25
|
+
class ArgsStreamer
|
|
26
|
+
attr_reader :scratch
|
|
27
|
+
|
|
28
|
+
# L3 scalar classification (frozen): a scalar that terminates is
|
|
29
|
+
# REPLACED by Integer/Float/true/false/nil when its raw text is a
|
|
30
|
+
# WELL-FORMED scalar, and keeps its raw-text String when malformed
|
|
31
|
+
# — the frozen clause's own examples are `01` and `1.`, so JSON's
|
|
32
|
+
# grammar (leading zeros, dangling fractions) gates the
|
|
33
|
+
# classification; the Integer/Float shapes below are the frozen
|
|
34
|
+
# forms minus the leading-zero overlap the malformed clause rules
|
|
35
|
+
# out. Integer for -?(0|[1-9]\d*), Float for that mantissa plus a
|
|
36
|
+
# fraction and/or exponent.
|
|
37
|
+
INTEGER_TEXT = /\A-?(?:0|[1-9]\d*)\z/
|
|
38
|
+
FLOAT_TEXT = /\A-?(?:(?:0|[1-9]\d*)\.\d+(?:[eE][+-]?\d+)?|(?:0|[1-9]\d*)(?:\.\d+)?[eE][+-]?\d+)\z/
|
|
39
|
+
|
|
40
|
+
# Fast path: a run of string-content bytes needing no per-byte
|
|
41
|
+
# handling — not a quote, not a backslash, not a raw control char,
|
|
42
|
+
# not a UTF-8 lead/continuation byte. Binary (/n) so it matches the
|
|
43
|
+
# byte view of the fragment.
|
|
44
|
+
PLAIN_RUN = /[^"\\\x00-\x1f\x80-\xff]+/n
|
|
45
|
+
|
|
46
|
+
# Byte constants (structure is always ASCII; content may be any
|
|
47
|
+
# byte — multibyte sequences are assembled, never mistaken for it).
|
|
48
|
+
QUOTE = 0x22
|
|
49
|
+
BACKSLASH = 0x5C
|
|
50
|
+
COLON = 0x3A
|
|
51
|
+
COMMA = 0x2C
|
|
52
|
+
OPEN_BRACE = 0x7B
|
|
53
|
+
CLOSE_BRACE = 0x7D
|
|
54
|
+
OPEN_BRACKET = 0x5B
|
|
55
|
+
CLOSE_BRACKET = 0x5D
|
|
56
|
+
|
|
57
|
+
def initialize(root_target)
|
|
58
|
+
@root = root_target
|
|
59
|
+
@scratch = +""
|
|
60
|
+
# Machine state (:value/:after_value refer to @stack.last; the
|
|
61
|
+
# frame layout is [container, :hash|:array]):
|
|
62
|
+
# :scan_root — before the root token (whitespace skipped)
|
|
63
|
+
# :frozen — root closed or structurally unparseable (L7);
|
|
64
|
+
# later bytes never mutate the live view again
|
|
65
|
+
# :key_first — object after `{`: first key or `}`
|
|
66
|
+
# :key — object after a comma: a key is required
|
|
67
|
+
# :colon — between a completed key and its value
|
|
68
|
+
# :value — a value token may start (or `]` closing an
|
|
69
|
+
# EMPTY array, per @may_close)
|
|
70
|
+
# :after_value— expecting `,` or the container's closer
|
|
71
|
+
# :in_key / :in_string — accumulating a key / a string value
|
|
72
|
+
# :in_scalar — accumulating raw scalar text
|
|
73
|
+
# :escape / :u_escape — mid-escape (held across deltas, L5/L6)
|
|
74
|
+
@state = :scan_root
|
|
75
|
+
@stack = []
|
|
76
|
+
@key = nil # completed key text for the pending pair
|
|
77
|
+
@buf = nil # the String being grown (a live value or a key)
|
|
78
|
+
@str_back = nil # :in_key or :in_string — where an escape returns
|
|
79
|
+
@cell = nil # [container, key_or_index] — the in-flight scalar slot
|
|
80
|
+
@scalar = nil # raw scalar text (replaced on termination, L3)
|
|
81
|
+
@digits = nil # collected hex digits of a \u run
|
|
82
|
+
@pending_high = nil # held high surrogate (L6)
|
|
83
|
+
@utf8_bytes = nil # partial multibyte sequence being assembled
|
|
84
|
+
@utf8_len = 0
|
|
85
|
+
@may_close = false # :value may see `]` (freshly opened array)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Append the fragment to the scratch and walk ONLY its bytes. An
|
|
89
|
+
# empty fragment is a no-op (L8); a frozen machine still accumulates
|
|
90
|
+
# the scratch — finalize, not this walk, is the authority (L9).
|
|
91
|
+
def consume(fragment)
|
|
92
|
+
return self unless fragment.is_a?(String)
|
|
93
|
+
|
|
94
|
+
# Scratch discipline (L8 totality — no exception ever leaves this
|
|
95
|
+
# file): the scratch stays UTF-8 so the finalize parse gets a
|
|
96
|
+
# JSON-parseable string. A same-encoding or ASCII-only fragment
|
|
97
|
+
# appends with no copy; anything else (a binary fragment cut
|
|
98
|
+
# mid-multibyte, odd encodings) is normalized byte-for-byte to
|
|
99
|
+
# UTF-8 first — invalid bytes stay invalid and finalize's repair
|
|
100
|
+
# path owns them. `bytes` is the walk view: binary for the
|
|
101
|
+
# byte-level regex, the fragment itself when it is ASCII-only
|
|
102
|
+
# (the regex accepts an ASCII-only string in any encoding).
|
|
103
|
+
bytes = fragment.ascii_only? ? fragment : fragment.b
|
|
104
|
+
if fragment.encoding == Encoding::UTF_8 || fragment.ascii_only?
|
|
105
|
+
@scratch << fragment
|
|
106
|
+
else
|
|
107
|
+
@scratch << bytes.dup.force_encoding(Encoding::UTF_8)
|
|
108
|
+
end
|
|
109
|
+
return self if @state == :frozen
|
|
110
|
+
|
|
111
|
+
frag = bytes
|
|
112
|
+
i = 0
|
|
113
|
+
length = frag.bytesize
|
|
114
|
+
while i < length
|
|
115
|
+
byte = frag.getbyte(i)
|
|
116
|
+
if ((@state == :in_string || @state == :in_key) && @utf8_bytes.nil?) &&
|
|
117
|
+
byte != QUOTE && byte != BACKSLASH && byte >= 0x20 && byte < 0x80
|
|
118
|
+
# Fast path: a plain run — one in-place append, one regex scan
|
|
119
|
+
# of the NEW bytes only. A held high surrogate drops here: its
|
|
120
|
+
# pair never lands (L6).
|
|
121
|
+
@pending_high = nil
|
|
122
|
+
run = PLAIN_RUN.match(frag, i)[0]
|
|
123
|
+
@buf << run
|
|
124
|
+
i += run.bytesize
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
i += 1
|
|
128
|
+
case @state
|
|
129
|
+
when :in_string, :in_key
|
|
130
|
+
consume_string_byte(byte) || (i -= 1) # false = reprocess the byte
|
|
131
|
+
when :escape
|
|
132
|
+
consume_escape_byte(byte) || (i -= 1)
|
|
133
|
+
when :u_escape
|
|
134
|
+
consume_unicode_byte(byte) || (i -= 1)
|
|
135
|
+
when :scan_root
|
|
136
|
+
case byte
|
|
137
|
+
when 0x09, 0x0A, 0x0D, 0x20 then nil # whitespace skipped
|
|
138
|
+
when OPEN_BRACE
|
|
139
|
+
@stack.push([@root, :hash]) # the live Hash populates (L7)
|
|
140
|
+
@state = :key_first
|
|
141
|
+
else # array, scalar, garbage before `{` — empty and frozen
|
|
142
|
+
@state = :frozen
|
|
143
|
+
end
|
|
144
|
+
when :key_first, :key
|
|
145
|
+
case byte
|
|
146
|
+
when 0x09, 0x0A, 0x0D, 0x20 then nil
|
|
147
|
+
when QUOTE
|
|
148
|
+
@buf = +""
|
|
149
|
+
@pending_high = nil
|
|
150
|
+
@utf8_bytes = nil
|
|
151
|
+
@state = :in_key
|
|
152
|
+
when CLOSE_BRACE
|
|
153
|
+
if @state == :key_first
|
|
154
|
+
close_container # `{}` — the empty object closes
|
|
155
|
+
else # after a comma a key is required: unparseable
|
|
156
|
+
@state = :frozen
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
@state = :frozen
|
|
160
|
+
end
|
|
161
|
+
when :colon
|
|
162
|
+
case byte
|
|
163
|
+
when 0x09, 0x0A, 0x0D, 0x20 then nil
|
|
164
|
+
when COLON
|
|
165
|
+
@may_close = false
|
|
166
|
+
@state = :value
|
|
167
|
+
else
|
|
168
|
+
@state = :frozen
|
|
169
|
+
end
|
|
170
|
+
when :value
|
|
171
|
+
case byte
|
|
172
|
+
when 0x09, 0x0A, 0x0D, 0x20 then nil
|
|
173
|
+
when QUOTE then open_string_value
|
|
174
|
+
when OPEN_BRACE then open_container({}, :hash)
|
|
175
|
+
when OPEN_BRACKET then open_container([], :array)
|
|
176
|
+
when CLOSE_BRACKET
|
|
177
|
+
if @may_close # `[]`; any other `]` here is unparseable
|
|
178
|
+
close_container
|
|
179
|
+
else
|
|
180
|
+
@state = :frozen
|
|
181
|
+
end
|
|
182
|
+
when CLOSE_BRACE, COMMA then @state = :frozen # no value can start with these
|
|
183
|
+
else
|
|
184
|
+
open_scalar(byte)
|
|
185
|
+
end
|
|
186
|
+
when :after_value
|
|
187
|
+
case byte
|
|
188
|
+
when 0x09, 0x0A, 0x0D, 0x20 then nil
|
|
189
|
+
when COMMA
|
|
190
|
+
if @stack.last[1] == :hash
|
|
191
|
+
@state = :key
|
|
192
|
+
else
|
|
193
|
+
@may_close = false
|
|
194
|
+
@state = :value
|
|
195
|
+
end
|
|
196
|
+
when CLOSE_BRACE
|
|
197
|
+
@stack.last[1] == :hash ? close_container : @state = :frozen
|
|
198
|
+
when CLOSE_BRACKET
|
|
199
|
+
@stack.last[1] == :array ? close_container : @state = :frozen
|
|
200
|
+
else
|
|
201
|
+
@state = :frozen
|
|
202
|
+
end
|
|
203
|
+
when :in_scalar
|
|
204
|
+
case byte
|
|
205
|
+
when COMMA, CLOSE_BRACE, CLOSE_BRACKET, 0x09, 0x0A, 0x0D, 0x20
|
|
206
|
+
# Token terminated (delimiter, closer, or whitespace): the
|
|
207
|
+
# raw-text String is REPLACED in its slot by the classified
|
|
208
|
+
# value (L3); the terminator itself is reprocessed below.
|
|
209
|
+
@cell[0][@cell[1]] = scalar_value(@scalar)
|
|
210
|
+
@state = :after_value
|
|
211
|
+
i -= 1
|
|
212
|
+
else
|
|
213
|
+
@scalar << byte
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
self
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# One byte inside a key or a string value. Returns false when the
|
|
223
|
+
# byte must be reprocessed (a broken multibyte sequence is dropped
|
|
224
|
+
# and the offending byte starts over).
|
|
225
|
+
def consume_string_byte(byte)
|
|
226
|
+
if @utf8_bytes
|
|
227
|
+
if byte >= 0x80 && byte <= 0xBF
|
|
228
|
+
@utf8_bytes << byte
|
|
229
|
+
if @utf8_bytes.size == @utf8_len
|
|
230
|
+
sequence = @utf8_bytes.pack("C*").force_encoding(Encoding::UTF_8)
|
|
231
|
+
@buf << sequence if sequence.valid_encoding?
|
|
232
|
+
@utf8_bytes = nil
|
|
233
|
+
end
|
|
234
|
+
else # the sequence never completes: dropped, byte reprocessed
|
|
235
|
+
@utf8_bytes = nil
|
|
236
|
+
return false
|
|
237
|
+
end
|
|
238
|
+
elsif byte >= 0x80
|
|
239
|
+
@pending_high = nil # a held high's pair never lands (L6)
|
|
240
|
+
if byte >= 0xC2 && byte <= 0xF4 # a valid lead byte; anything else is dropped
|
|
241
|
+
@utf8_len = byte < 0xE0 ? 2 : (byte < 0xF0 ? 3 : 4)
|
|
242
|
+
@utf8_bytes = [byte]
|
|
243
|
+
end
|
|
244
|
+
elsif byte == QUOTE # the string closes
|
|
245
|
+
@pending_high = nil # a held high's pair never lands (L6)
|
|
246
|
+
if @state == :in_key
|
|
247
|
+
@key = @buf
|
|
248
|
+
@state = :colon
|
|
249
|
+
else
|
|
250
|
+
@state = :after_value
|
|
251
|
+
end
|
|
252
|
+
elsif byte == BACKSLASH
|
|
253
|
+
@str_back = @state
|
|
254
|
+
@state = :escape
|
|
255
|
+
else # plain ASCII content, raw control characters included (L5)
|
|
256
|
+
@pending_high = nil
|
|
257
|
+
@buf << byte
|
|
258
|
+
end
|
|
259
|
+
true
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The byte after a backslash. Returns false when the byte must be
|
|
263
|
+
# reprocessed (an invalid escape char contributes the literal
|
|
264
|
+
# backslash, then the char itself is handled as content, L5).
|
|
265
|
+
def consume_escape_byte(byte)
|
|
266
|
+
case byte
|
|
267
|
+
when 0x75 # u — the hex run starts; a held high may still pair (L6)
|
|
268
|
+
@digits = +""
|
|
269
|
+
@state = :u_escape
|
|
270
|
+
when QUOTE then settle_escape("\"")
|
|
271
|
+
when BACKSLASH then settle_escape("\\")
|
|
272
|
+
when 0x2F then settle_escape("/") # \/ — repair_json parity
|
|
273
|
+
when 0x62 then settle_escape("\b")
|
|
274
|
+
when 0x66 then settle_escape("\f")
|
|
275
|
+
when 0x6E then settle_escape("\n")
|
|
276
|
+
when 0x72 then settle_escape("\r")
|
|
277
|
+
when 0x74 then settle_escape("\t")
|
|
278
|
+
else
|
|
279
|
+
@pending_high = nil
|
|
280
|
+
@buf << "\\" # invalid escape char: literal `\`, char reprocessed
|
|
281
|
+
@state = @str_back
|
|
282
|
+
return false
|
|
283
|
+
end
|
|
284
|
+
true
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def settle_escape(text)
|
|
288
|
+
@pending_high = nil # a held high's pair never lands (L6)
|
|
289
|
+
@buf << text
|
|
290
|
+
@state = @str_back
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# One byte of a \u run. Returns false when the byte must be
|
|
294
|
+
# reprocessed (an invalidated run contributes literal `\u` + the
|
|
295
|
+
# digits collected, then the offending byte is handled as content,
|
|
296
|
+
# L5 — a short run invalidated by the string's closing quote is the
|
|
297
|
+
# "short at close" case).
|
|
298
|
+
def consume_unicode_byte(byte)
|
|
299
|
+
if hex_byte?(byte)
|
|
300
|
+
@digits << byte
|
|
301
|
+
if @digits.size == 4
|
|
302
|
+
codepoint = @digits.to_i(16)
|
|
303
|
+
if codepoint >= 0xD800 && codepoint <= 0xDBFF
|
|
304
|
+
@pending_high = codepoint # held for a possible pair (L6)
|
|
305
|
+
elsif codepoint >= 0xDC00 && codepoint <= 0xDFFF
|
|
306
|
+
if @pending_high # the pair combines into one codepoint (L6)
|
|
307
|
+
pair = 0x10000 + ((@pending_high & 0x3FF) << 10) + (codepoint & 0x3FF)
|
|
308
|
+
@buf << pair
|
|
309
|
+
@pending_high = nil
|
|
310
|
+
end # a lone low half never enters the live value (L6)
|
|
311
|
+
else
|
|
312
|
+
@pending_high = nil
|
|
313
|
+
@buf << codepoint
|
|
314
|
+
end
|
|
315
|
+
@state = @str_back
|
|
316
|
+
end
|
|
317
|
+
else
|
|
318
|
+
@pending_high = nil
|
|
319
|
+
@buf << "\\u"
|
|
320
|
+
@buf << @digits
|
|
321
|
+
@state = @str_back
|
|
322
|
+
return false
|
|
323
|
+
end
|
|
324
|
+
true
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def hex_byte?(byte)
|
|
328
|
+
(byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x46) ||
|
|
329
|
+
(byte >= 0x61 && byte <= 0x66)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# A string value materializes at its OPENING quote — the slot exists
|
|
333
|
+
# with "" before any content byte (L3) — and grows by in-place
|
|
334
|
+
# append for the rest of the stream (L1).
|
|
335
|
+
def open_string_value
|
|
336
|
+
value = +""
|
|
337
|
+
attach(value)
|
|
338
|
+
@buf = value
|
|
339
|
+
@pending_high = nil
|
|
340
|
+
@utf8_bytes = nil
|
|
341
|
+
@state = :in_string
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# An object/array value materializes at its opening token and is
|
|
345
|
+
# inserted into its parent immediately (L3), then mutated in place
|
|
346
|
+
# (L1). A fresh array may still be closed empty while @may_close.
|
|
347
|
+
def open_container(container, kind)
|
|
348
|
+
attach(container)
|
|
349
|
+
@stack.push([container, kind])
|
|
350
|
+
if kind == :hash
|
|
351
|
+
@state = :key_first
|
|
352
|
+
else
|
|
353
|
+
@may_close = true
|
|
354
|
+
@state = :value
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# A scalar materializes as its raw-text String at its FIRST byte
|
|
359
|
+
# (L3), inserted into its parent; the slot is replaced when the
|
|
360
|
+
# token terminates (see :in_scalar above).
|
|
361
|
+
def open_scalar(byte)
|
|
362
|
+
@scalar = +""
|
|
363
|
+
@scalar << byte
|
|
364
|
+
attach(@scalar)
|
|
365
|
+
@state = :in_scalar
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Insert a fresh value into the innermost container. Duplicate keys
|
|
369
|
+
# collapse to one slot, last value wins (L2); array elements append.
|
|
370
|
+
# The slot pair also serves scalar replacement — `container[key]=`
|
|
371
|
+
# and `container[index]=` are the same expression for both kinds.
|
|
372
|
+
def attach(value)
|
|
373
|
+
frame = @stack.last
|
|
374
|
+
if frame[1] == :hash
|
|
375
|
+
frame[0][@key] = value
|
|
376
|
+
@cell = [frame[0], @key]
|
|
377
|
+
else
|
|
378
|
+
@cell = [frame[0], frame[0].size]
|
|
379
|
+
frame[0] << value
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Close the innermost container. The root object closing (or the
|
|
384
|
+
# last frame popping empty) freezes the live view (L7).
|
|
385
|
+
def close_container
|
|
386
|
+
@stack.pop
|
|
387
|
+
@state = @stack.empty? ? :frozen : :after_value
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def scalar_value(text)
|
|
391
|
+
case text
|
|
392
|
+
when "true" then true
|
|
393
|
+
when "false" then false
|
|
394
|
+
when "null" then nil
|
|
395
|
+
else
|
|
396
|
+
if text.match?(INTEGER_TEXT) then text.to_i
|
|
397
|
+
elsif text.match?(FLOAT_TEXT) then text.to_f
|
|
398
|
+
else text # malformed (`01`, `1.` at close): keeps its raw text
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "args_streamer"
|
|
4
|
+
|
|
3
5
|
module Fast
|
|
4
6
|
module Completions
|
|
5
7
|
# THE BYTE CONTRACT (Specification §9): Ruby Hash insertion order +
|
|
@@ -311,7 +313,7 @@ module Fast
|
|
|
311
313
|
@thinking_block = nil
|
|
312
314
|
@tool_by_index = {}
|
|
313
315
|
@tool_by_id = {}
|
|
314
|
-
@
|
|
316
|
+
@args_streamers = {}.compare_by_identity # per-block ArgsStreamer (I02); each owns its scratch
|
|
315
317
|
@streamed_details = nil # :334-341
|
|
316
318
|
@has_finish_reason = false
|
|
317
319
|
@stop_reason = nil
|
|
@@ -440,7 +442,7 @@ module Fast
|
|
|
440
442
|
# its block — by index, by id, or by creation — while CARRYING an
|
|
441
443
|
# index the block has never bound, binds it here. FIRST index
|
|
442
444
|
# wins; one index per block, ever. Boundness is tracked
|
|
443
|
-
# accumulator-side (identity-keyed, like @
|
|
445
|
+
# accumulator-side (identity-keyed, like @args_streamers): the
|
|
444
446
|
# blocks ARE message content — no scratch keys on them.
|
|
445
447
|
@tool_index_bound ||= {}.compare_by_identity
|
|
446
448
|
if index && !@tool_index_bound.key?(block)
|
|
@@ -450,8 +452,15 @@ module Fast
|
|
|
450
452
|
|
|
451
453
|
raw = entry.dig("function", "arguments")
|
|
452
454
|
raw = "" unless raw.is_a?(String)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
+
# I02 (0.5.0): the incremental args machine. The old seam — a
|
|
456
|
+
# full-buffer copy plus an eager whole-scratch re-parse per delta
|
|
457
|
+
# — is gone. This is a DELIBERATE departure from pi, which
|
|
458
|
+
# re-parses per delta (openai-completions.ts :652-658): the
|
|
459
|
+
# streamer carries parser state across deltas, walks ONLY the
|
|
460
|
+
# new fragment's bytes, and grows the live view in place (the
|
|
461
|
+
# block's arguments Hash); the one authoritative whole-buffer
|
|
462
|
+
# parse still happens at finalize (finalize_blocks below).
|
|
463
|
+
(@args_streamers[block] ||= ArgsStreamer.new(block[:arguments])).consume(raw)
|
|
455
464
|
events << Event.new(type: :tool_call_delta, content_index: content_index(block),
|
|
456
465
|
delta: raw, partial: @partial)
|
|
457
466
|
events
|
|
@@ -469,7 +478,13 @@ module Fast
|
|
|
469
478
|
block[:signature] = JSON.generate(@streamed_details) if @streamed_details
|
|
470
479
|
events << Event.new(type: :thinking_end, content_index: index, content: block[:thinking], partial: @partial)
|
|
471
480
|
when :tool_call
|
|
472
|
-
|
|
481
|
+
streamer = @args_streamers.delete(block)
|
|
482
|
+
raw = streamer ? streamer.scratch : "" # no-fragment blocks finalize an empty scratch
|
|
483
|
+
# Finalize is the SOLE authority (L9): one whole-buffer parse
|
|
484
|
+
# of the raw bytes — the JSON.parse fast path when the stream
|
|
485
|
+
# completed, the repair walk + {} last resort otherwise
|
|
486
|
+
# (pi :453 / utils/json-parse.ts :20-90). The live Hash the
|
|
487
|
+
# machine grew is replaced here, exactly once.
|
|
473
488
|
block[:arguments] = Protocol.parse_streaming_json(raw)
|
|
474
489
|
events << Event.new(type: :tool_call_end, content_index: index,
|
|
475
490
|
tool_call: {id: block[:id], name: block[:name], arguments: block[:arguments]},
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fast-completions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Jacobs
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- CHANGELOG.md
|
|
35
35
|
- README.md
|
|
36
36
|
- lib/fast/completions.rb
|
|
37
|
+
- lib/fast/completions/args_streamer.rb
|
|
37
38
|
- lib/fast/completions/cancellation.rb
|
|
38
39
|
- lib/fast/completions/chat.rb
|
|
39
40
|
- lib/fast/completions/client.rb
|