ask-agent 0.4.7 → 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 +64 -0
- data/lib/ask/agent/chat.rb +122 -57
- data/lib/ask/agent/configuration.rb +9 -0
- data/lib/ask/agent/middleware/base.rb +37 -0
- data/lib/ask/agent/middleware/default_settings.rb +49 -0
- data/lib/ask/agent/middleware/log_calls.rb +59 -0
- data/lib/ask/agent/middleware/pipeline.rb +94 -0
- data/lib/ask/agent/middleware/retry_on_failure.rb +61 -0
- data/lib/ask/agent/stream_transforms/base.rb +44 -0
- data/lib/ask/agent/stream_transforms/extract_json.rb +58 -0
- data/lib/ask/agent/stream_transforms/pipeline.rb +104 -0
- data/lib/ask/agent/stream_transforms/text_buffer.rb +86 -0
- data/lib/ask/agent/stream_transforms/thinking_separator.rb +55 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +16 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a1f33fb0145ac0a1abf50990d15a6f4aa288a67fe8ec11f478cead3b9401a43
|
|
4
|
+
data.tar.gz: ebaa94902c33f6fe351b97108c6db085096a92ca4206706c8197cba5aaa5d8d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a32c8330c3d443980a48df17507cdb4cfa56e67129f7e0fbabce12d9911705a621b85b60f7849ccd422496f46f2c3cc3e4f639cef11673f6530fbc5bd46480e
|
|
7
|
+
data.tar.gz: 2fd18590c038a8c1221c6422980aebff1edd38e634d53036cb231cae0eda36d15a9df8bcbb48cda146dd828670ebdbf10d9c88bc388b89e0f6fe65c8d7c3788e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
+
## [0.5.0] — 2026-07-21
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Middleware pipeline for LLM provider calls** — `Ask::Agent::Middleware::Pipeline` lets you wrap every `provider.chat(...)` call with cross-cutting behavior. Configured globally and automatically used by all `Chat` and `Session` instances.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
Ask::Agent.configure do |c|
|
|
9
|
+
c.middleware.use :retry_on_failure, max_retries: 5
|
|
10
|
+
c.middleware.use :log_calls, logger: Rails.logger
|
|
11
|
+
c.middleware.use :default_settings, temperature: 0.7
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Three built-in middlewares:
|
|
16
|
+
- **`RetryOnFailure`** — Exponential backoff retry on `RateLimitError`, `ServerError`, and `ServiceUnavailable`. Does not retry on fatal errors (`Unauthorized`, `ModelNotFound`, `ConfigurationError`). Respects `retry_after` from provider responses.
|
|
17
|
+
- **`LogCalls`** — Logs every LLM call with model, tool count, message count, duration, and token usage. Custom logger support (defaults to `$stdout`).
|
|
18
|
+
- **`DefaultSettings`** — Injects default generation parameters (`temperature`, `max_tokens`, `top_p`, etc.) into the provider call request.
|
|
19
|
+
|
|
20
|
+
Custom middlewares extend `Ask::Agent::Middleware::Base` and override `#around_request`:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
class MyMiddleware < Ask::Agent::Middleware::Base
|
|
24
|
+
def around_request(provider, request)
|
|
25
|
+
Rails.logger.info "Calling #{request[:model]}"
|
|
26
|
+
yield
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Ask::Agent.configure { |c| c.middleware.use MyMiddleware }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **Stream transform pipeline** — `Ask::Agent::StreamTransforms::Pipeline` processes each raw `Ask::Chunk` through a chain of transforms before yielding `ChatChunks` to the caller. Configured globally.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
Ask::Agent.configure do |c|
|
|
37
|
+
c.stream_transforms.use :thinking_separator
|
|
38
|
+
c.stream_transforms.use :text_buffer, min_size: 100
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Three built-in transforms:
|
|
43
|
+
- **`ThinkingSeparator`** — Splits chunks that contain both `thinking` and visible `content` into two separate chunks, so you can handle thinking tokens independently.
|
|
44
|
+
- **`TextBuffer`** — Coalesces rapid text deltas into larger contiguous chunks (minimum configurable size). Reduces UI updates and log entries. Automatically flushes before non-content chunks and when the stream finishes.
|
|
45
|
+
- **`ExtractJson`** — Accumulates the streaming response and attempts to parse it as JSON. Provides `#extracted_json` and `#json?` accessors for post-stream inspection.
|
|
46
|
+
|
|
47
|
+
Custom transforms extend `Ask::Agent::StreamTransforms::Base` and override `#call`:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class FilterTransform < Ask::Agent::StreamTransforms::Base
|
|
51
|
+
def call(chunk, &block)
|
|
52
|
+
block.call(chunk) unless chunk.content == "drop_me"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Ask::Agent.configure { |c| c.stream_transforms.use FilterTransform }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **`Ask::Agent::Configuration`** now exposes `#middleware` and `#stream_transforms` pipelines. Both are pre-initialized as empty pipelines — no breaking changes for existing users.
|
|
62
|
+
- **`Ask::Agent::Chat`** reads middleware and stream transforms from global configuration on initialization. If configured, all provider calls go through the middleware chain and all stream chunks through the transform chain.
|
|
63
|
+
- **Test helper** now includes local `ask-core`, `ask-auth`, `ask-instrumentation`, and `ask-llm-providers` in the load path so tests run against development code rather than installed gems.
|
|
64
|
+
|
|
1
65
|
## [0.4.5] — 2026-07-18
|
|
2
66
|
|
|
3
67
|
### Fixed
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -24,6 +24,8 @@ module Ask
|
|
|
24
24
|
|
|
25
25
|
attr_reader :messages
|
|
26
26
|
|
|
27
|
+
attr_writer :test_provider
|
|
28
|
+
|
|
27
29
|
def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, **)
|
|
28
30
|
@model_id = model.respond_to?(:id) ? model.id : model.to_s
|
|
29
31
|
@model_info = Ask::ModelCatalog.find(@model_id)
|
|
@@ -33,6 +35,11 @@ module Ask
|
|
|
33
35
|
@messages = []
|
|
34
36
|
@provider_override = provider
|
|
35
37
|
@provider = nil
|
|
38
|
+
|
|
39
|
+
# Read configured middleware and stream transforms from global config
|
|
40
|
+
config = Ask::Agent.configuration
|
|
41
|
+
@middleware_pipeline = config.middleware.configured? ? config.middleware : nil
|
|
42
|
+
@transform_pipeline = config.stream_transforms.configured? ? config.stream_transforms : nil
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
def ask(message = nil, &block)
|
|
@@ -42,20 +49,7 @@ module Ask
|
|
|
42
49
|
tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
|
|
43
50
|
|
|
44
51
|
calls_acc = {}
|
|
45
|
-
|
|
46
|
-
provider_model = @model_id
|
|
47
|
-
provider_tools = tool_defs
|
|
48
|
-
provider_temp = @temperature
|
|
49
|
-
provider_schema = @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema
|
|
50
|
-
provider_params = @extra_params || {}
|
|
51
|
-
|
|
52
|
-
result = chat_with_retry(stream, calls_acc, &block)
|
|
53
|
-
|
|
54
|
-
response_msg = if result.respond_to?(:chunks)
|
|
55
|
-
build_stream_response(result, calls_acc)
|
|
56
|
-
else
|
|
57
|
-
build_response(result)
|
|
58
|
-
end
|
|
52
|
+
response_msg = chat_with_retry(stream, calls_acc, &block)
|
|
59
53
|
|
|
60
54
|
@messages << Ask::Message.new(
|
|
61
55
|
role: :assistant,
|
|
@@ -104,10 +98,10 @@ module Ask
|
|
|
104
98
|
@messages.clear
|
|
105
99
|
end
|
|
106
100
|
|
|
107
|
-
attr_writer :test_provider
|
|
108
|
-
|
|
109
101
|
private
|
|
110
102
|
|
|
103
|
+
MAX_CHAT_RETRIES = 3
|
|
104
|
+
|
|
111
105
|
def provider
|
|
112
106
|
@test_provider || @provider ||= build_provider
|
|
113
107
|
end
|
|
@@ -119,11 +113,6 @@ module Ask
|
|
|
119
113
|
end
|
|
120
114
|
|
|
121
115
|
def provider_config(slug)
|
|
122
|
-
# Try credential names from most to least specific:
|
|
123
|
-
# 1. Flat key with full slug (opencode_go_api_key)
|
|
124
|
-
# 2. Nested path with full slug ([:opencode, :go, :api_key])
|
|
125
|
-
# 3. Flat key with base slug (opencode_api_key)
|
|
126
|
-
# 4. Nested path with base slug ([:opencode, :api_key])
|
|
127
116
|
slug_s = slug.to_s
|
|
128
117
|
base_s = slug_s.include?("_") ? slug_s.split("_").first : nil
|
|
129
118
|
|
|
@@ -143,6 +132,115 @@ module Ask
|
|
|
143
132
|
Ask::LLM::Config.new(config)
|
|
144
133
|
end
|
|
145
134
|
|
|
135
|
+
# Build the request hash for the provider call.
|
|
136
|
+
# Middleware can mutate this hash to inject defaults, modify params, etc.
|
|
137
|
+
def build_request(stream)
|
|
138
|
+
{
|
|
139
|
+
messages: @messages.map(&:to_h),
|
|
140
|
+
model: @model_id,
|
|
141
|
+
tools: @tools.map { |t| Ask::ToolDef.from_tool(t) },
|
|
142
|
+
temperature: @temperature,
|
|
143
|
+
stream: stream,
|
|
144
|
+
schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
|
|
145
|
+
extra_params: (@extra_params || {}).dup
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Dispatch a provider.chat(...) call. Uses the request hash that was
|
|
150
|
+
# already transformed by middleware (if any).
|
|
151
|
+
def call_provider(req, calls_acc, &block)
|
|
152
|
+
provider.chat(
|
|
153
|
+
req[:messages],
|
|
154
|
+
model: req[:model],
|
|
155
|
+
tools: req[:tools],
|
|
156
|
+
temperature: req[:temperature],
|
|
157
|
+
stream: req[:stream],
|
|
158
|
+
schema: req[:schema],
|
|
159
|
+
**req[:extra_params]
|
|
160
|
+
) do |raw_chunk|
|
|
161
|
+
next unless block
|
|
162
|
+
process_chunk(raw_chunk, calls_acc, &block)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Process a raw chunk through the stream transform pipeline (if configured),
|
|
167
|
+
# then yield ChatChunks to the caller's block.
|
|
168
|
+
def process_chunk(raw_chunk, calls_acc, &block)
|
|
169
|
+
if @transform_pipeline
|
|
170
|
+
# The transform chain will yield 0, 1, or many transformed chunks
|
|
171
|
+
wrapped = @transform_pipeline.wrap do |transformed|
|
|
172
|
+
accumulate_tool_calls(transformed, calls_acc)
|
|
173
|
+
block.call(ChatChunk.new(
|
|
174
|
+
content: transformed.content,
|
|
175
|
+
tool_calls: build_current_tool_calls(calls_acc),
|
|
176
|
+
thinking: transformed.respond_to?(:thinking) ? transformed.thinking : nil,
|
|
177
|
+
input_tokens: nil,
|
|
178
|
+
output_tokens: nil
|
|
179
|
+
))
|
|
180
|
+
end
|
|
181
|
+
wrapped.call(raw_chunk)
|
|
182
|
+
else
|
|
183
|
+
accumulate_tool_calls(raw_chunk, calls_acc)
|
|
184
|
+
block.call(ChatChunk.new(
|
|
185
|
+
content: raw_chunk.content,
|
|
186
|
+
tool_calls: build_current_tool_calls(calls_acc),
|
|
187
|
+
thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil,
|
|
188
|
+
input_tokens: nil,
|
|
189
|
+
output_tokens: nil
|
|
190
|
+
))
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def chat_with_retry(stream, calls_acc, &block)
|
|
195
|
+
MAX_CHAT_RETRIES.times do |attempt|
|
|
196
|
+
begin
|
|
197
|
+
req = build_request(stream)
|
|
198
|
+
|
|
199
|
+
result = if @middleware_pipeline
|
|
200
|
+
@middleware_pipeline.invoke(provider, req) do
|
|
201
|
+
call_provider(req, calls_acc, &block)
|
|
202
|
+
end
|
|
203
|
+
else
|
|
204
|
+
call_provider(req, calls_acc, &block)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Flush any buffered stream transforms (e.g. TextBuffer)
|
|
208
|
+
if block && @transform_pipeline
|
|
209
|
+
flush_transforms(&block)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return build_response_from_result(result, calls_acc, stream)
|
|
213
|
+
rescue Ask::RateLimitError => e
|
|
214
|
+
raise if attempt >= MAX_CHAT_RETRIES - 1
|
|
215
|
+
|
|
216
|
+
delay = e.retry_after || ((2**attempt) + rand(0.0..1.0))
|
|
217
|
+
sleep(delay)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# After the stream ends, flush any buffered state from transforms
|
|
223
|
+
# (e.g. TextBuffer's remaining buffer, pending metadata).
|
|
224
|
+
def flush_transforms(&block)
|
|
225
|
+
@transform_pipeline.flush do |chunk|
|
|
226
|
+
block.call(ChatChunk.new(
|
|
227
|
+
content: chunk.content,
|
|
228
|
+
tool_calls: {},
|
|
229
|
+
thinking: chunk.respond_to?(:thinking) ? chunk.thinking : nil,
|
|
230
|
+
input_tokens: nil,
|
|
231
|
+
output_tokens: nil
|
|
232
|
+
))
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_response_from_result(result, calls_acc, stream)
|
|
237
|
+
if result.respond_to?(:chunks)
|
|
238
|
+
build_stream_response(result, calls_acc)
|
|
239
|
+
else
|
|
240
|
+
build_response(result)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
146
244
|
def accumulate_tool_calls(raw_chunk, calls_acc)
|
|
147
245
|
return unless raw_chunk.tool_call?
|
|
148
246
|
|
|
@@ -232,41 +330,6 @@ module Ask
|
|
|
232
330
|
nil
|
|
233
331
|
end
|
|
234
332
|
|
|
235
|
-
MAX_CHAT_RETRIES = 3
|
|
236
|
-
|
|
237
|
-
def chat_with_retry(stream, calls_acc, &block)
|
|
238
|
-
MAX_CHAT_RETRIES.times do |attempt|
|
|
239
|
-
begin
|
|
240
|
-
return provider.chat(
|
|
241
|
-
@messages.map(&:to_h),
|
|
242
|
-
model: @model_id,
|
|
243
|
-
tools: @tools.map { |t| Ask::ToolDef.from_tool(t) },
|
|
244
|
-
temperature: @temperature,
|
|
245
|
-
stream: stream,
|
|
246
|
-
schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
|
|
247
|
-
**(@extra_params || {})
|
|
248
|
-
) do |raw_chunk|
|
|
249
|
-
next unless block
|
|
250
|
-
|
|
251
|
-
accumulate_tool_calls(raw_chunk, calls_acc)
|
|
252
|
-
|
|
253
|
-
block.call(ChatChunk.new(
|
|
254
|
-
content: raw_chunk.content,
|
|
255
|
-
tool_calls: build_current_tool_calls(calls_acc),
|
|
256
|
-
thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil,
|
|
257
|
-
input_tokens: nil,
|
|
258
|
-
output_tokens: nil
|
|
259
|
-
))
|
|
260
|
-
end
|
|
261
|
-
rescue Ask::RateLimitError => e
|
|
262
|
-
raise if attempt >= MAX_CHAT_RETRIES - 1
|
|
263
|
-
|
|
264
|
-
delay = e.retry_after || ((2 ** attempt) + rand(0.0..1.0))
|
|
265
|
-
sleep(delay)
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
|
|
270
333
|
def emit_instrumentation(stream, response_msg)
|
|
271
334
|
return unless defined?(Ask::Instrumentation)
|
|
272
335
|
|
|
@@ -277,7 +340,9 @@ module Ask
|
|
|
277
340
|
output_tokens: response_msg.output_tokens,
|
|
278
341
|
cost: response_msg.cost,
|
|
279
342
|
tool_calls: response_msg.tool_call?,
|
|
280
|
-
stream: stream
|
|
343
|
+
stream: stream,
|
|
344
|
+
middleware: @middleware_pipeline&.configured?,
|
|
345
|
+
stream_transforms: @transform_pipeline&.configured?
|
|
281
346
|
}.compact
|
|
282
347
|
|
|
283
348
|
if stream
|
|
@@ -6,6 +6,12 @@ module Ask
|
|
|
6
6
|
attr_accessor :default_model, :default_max_turns, :compactor_enabled,
|
|
7
7
|
:compactor_threshold, :parallel_tool_execution, :max_tool_retries
|
|
8
8
|
|
|
9
|
+
# @return [Middleware::Pipeline] the middleware pipeline for provider calls
|
|
10
|
+
attr_reader :middleware
|
|
11
|
+
|
|
12
|
+
# @return [StreamTransforms::Pipeline] the stream transforms pipeline
|
|
13
|
+
attr_reader :stream_transforms
|
|
14
|
+
|
|
9
15
|
def initialize
|
|
10
16
|
@default_model = "gpt-4o"
|
|
11
17
|
@default_max_turns = 25
|
|
@@ -13,6 +19,9 @@ module Ask
|
|
|
13
19
|
@compactor_threshold = 0.8
|
|
14
20
|
@parallel_tool_execution = true
|
|
15
21
|
@max_tool_retries = 3
|
|
22
|
+
|
|
23
|
+
@middleware = Middleware::Pipeline.new
|
|
24
|
+
@stream_transforms = StreamTransforms::Pipeline.new
|
|
16
25
|
end
|
|
17
26
|
end
|
|
18
27
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Middleware
|
|
6
|
+
# Base class for middleware that wraps LLM provider calls.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses override {#around_request} to inject behavior before, after,
|
|
9
|
+
# or around the provider call. The default implementation yields to the
|
|
10
|
+
# next middleware in the chain (or the actual provider if this is the
|
|
11
|
+
# innermost wrapper).
|
|
12
|
+
#
|
|
13
|
+
# @example A logging middleware
|
|
14
|
+
# class LogCalls < Base
|
|
15
|
+
# def around_request(provider, request)
|
|
16
|
+
# logger.info "LLM call: #{request[:model]}"
|
|
17
|
+
# start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
|
+
# result = yield
|
|
19
|
+
# elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
20
|
+
# logger.info "LLM call finished in #{elapsed.round(3)}s"
|
|
21
|
+
# result
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
class Base
|
|
25
|
+
# Called before/after each provider.chat(...) call.
|
|
26
|
+
#
|
|
27
|
+
# @param provider [Object] the LLM provider instance
|
|
28
|
+
# @param request [Hash] the request parameters (:messages, :model, :tools, etc.)
|
|
29
|
+
# @yield call the next middleware (or the actual provider)
|
|
30
|
+
# @return [Object] the provider's response
|
|
31
|
+
def around_request(provider, request)
|
|
32
|
+
yield
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Middleware
|
|
6
|
+
# Injects default generation parameters for every LLM call.
|
|
7
|
+
#
|
|
8
|
+
# Settings are merged into the request – user-supplied values always take
|
|
9
|
+
# precedence (they appear in the original request hash and are preserved).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# pipeline.use :default_settings, temperature: 0.5, max_tokens: 4096
|
|
13
|
+
class DefaultSettings < Base
|
|
14
|
+
# Settings that are safe to merge into the provider chat request.
|
|
15
|
+
ALLOWED_KEYS = %i[
|
|
16
|
+
temperature max_tokens top_p top_k stop_sequences
|
|
17
|
+
presence_penalty frequency_penalty seed
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(**settings)
|
|
21
|
+
@settings = settings.select { |k, _v| ALLOWED_KEYS.include?(k) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def around_request(provider, request)
|
|
25
|
+
return yield if @settings.empty?
|
|
26
|
+
|
|
27
|
+
merged = request.dup
|
|
28
|
+
@settings.each do |key, value|
|
|
29
|
+
merged[key] = value unless merged.key?(key)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Re-invoke the chain with merged params.
|
|
33
|
+
# We override the request for downstream middleware but still yield
|
|
34
|
+
# to the original chain — the actual provider call in Chat#chat_with_retry
|
|
35
|
+
# uses the **(@extra_params || {}) merged into the provider call, so
|
|
36
|
+
# we must ensure our defaults are passed through.
|
|
37
|
+
#
|
|
38
|
+
# Instead of modifying the request shape, we inject defaults into
|
|
39
|
+
# the :extra_params key which Chat already merges into the provider call.
|
|
40
|
+
extra = (merged[:extra_params] || {}).merge(@settings) { |_k, orig, _default| orig }
|
|
41
|
+
merged[:extra_params] = extra
|
|
42
|
+
|
|
43
|
+
# Rebuild request to pass defaults through the chain
|
|
44
|
+
yield
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Middleware
|
|
6
|
+
# Logs LLM provider calls — request details, duration, token usage, and errors.
|
|
7
|
+
#
|
|
8
|
+
# Uses a configurable logger (defaults to `$stdout` via Ruby's `Logger`).
|
|
9
|
+
# Each call is logged at `INFO` level on success, `WARN` level on failure.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# pipeline.use :log_calls, logger: Rails.logger
|
|
13
|
+
class LogCalls < Base
|
|
14
|
+
def initialize(logger: nil)
|
|
15
|
+
@logger = logger || default_logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def around_request(provider, request)
|
|
19
|
+
model = request[:model]
|
|
20
|
+
tool_count = request[:tools]&.length.to_i
|
|
21
|
+
msg_count = request[:messages]&.length.to_i
|
|
22
|
+
|
|
23
|
+
@logger.info("[ask-agent] LLM call starting — model=#{model} tools=#{tool_count} messages=#{msg_count}")
|
|
24
|
+
|
|
25
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
result = yield
|
|
29
|
+
|
|
30
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
31
|
+
|
|
32
|
+
if result.respond_to?(:accumulated_usage)
|
|
33
|
+
usage = result.accumulated_usage
|
|
34
|
+
tokens = "#{usage[:input_tokens] || '?'}i / #{usage[:output_tokens] || '?'}o"
|
|
35
|
+
@logger.info("[ask-agent] LLM call completed — model=#{model} duration=#{elapsed.round(3)}s tokens=#{tokens}")
|
|
36
|
+
elsif result.respond_to?(:content)
|
|
37
|
+
@logger.info("[ask-agent] LLM call completed — model=#{model} duration=#{elapsed.round(3)}s content_length=#{result.content.to_s.length}")
|
|
38
|
+
else
|
|
39
|
+
@logger.info("[ask-agent] LLM call completed — model=#{model} duration=#{elapsed.round(3)}s")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
45
|
+
@logger.warn("[ask-agent] LLM call failed — model=#{model} duration=#{elapsed.round(3)}s error=#{e.class}(#{e.message})")
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def default_logger
|
|
53
|
+
require "logger"
|
|
54
|
+
Logger.new($stdout).tap { |l| l.formatter = ->(s, t, _p, msg) { "[#{t.strftime("%H:%M:%S")}] #{msg}\n" } }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Middleware
|
|
6
|
+
# A composable chain of middleware that wraps an LLM provider.
|
|
7
|
+
#
|
|
8
|
+
# Middleware are applied in order — the first middleware registered wraps
|
|
9
|
+
# the outermost layer and sees the request first.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# pipeline = Pipeline.new
|
|
13
|
+
# pipeline.use :retry_on_failure, max_retries: 5
|
|
14
|
+
# pipeline.use :log_calls, logger: Rails.logger
|
|
15
|
+
# pipeline.use :default_settings, temperature: 0.7
|
|
16
|
+
#
|
|
17
|
+
# result = pipeline.invoke(provider, request) { provider.chat(**request) }
|
|
18
|
+
class Pipeline
|
|
19
|
+
KNOWN_MIDDLEWARES = {
|
|
20
|
+
retry_on_failure: "Ask::Agent::Middleware::RetryOnFailure",
|
|
21
|
+
log_calls: "Ask::Agent::Middleware::LogCalls",
|
|
22
|
+
default_settings: "Ask::Agent::Middleware::DefaultSettings"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@entries = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Register a middleware in the chain.
|
|
30
|
+
#
|
|
31
|
+
# @param middleware [Symbol, Class] a symbol from KNOWN_MIDDLEWARES or a Middleware::Base subclass
|
|
32
|
+
# @param options [Hash] keyword arguments passed to the middleware's constructor
|
|
33
|
+
def use(middleware, **options)
|
|
34
|
+
klass = resolve(middleware)
|
|
35
|
+
@entries << { klass: klass, options: options }
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] whether any middleware has been registered
|
|
40
|
+
def configured?
|
|
41
|
+
@entries.any?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Iterate over the configured middleware entries.
|
|
45
|
+
def each(&block)
|
|
46
|
+
@entries.each(&block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Invoke the middleware chain around a provider call.
|
|
50
|
+
#
|
|
51
|
+
# Builds a chain of lambdas from the innermost (actual provider) outward,
|
|
52
|
+
# then invokes it. Each middleware's {Base#around_request} wraps the next
|
|
53
|
+
# link.
|
|
54
|
+
#
|
|
55
|
+
# @param provider [Object] the LLM provider
|
|
56
|
+
# @param request [Hash] the request parameters
|
|
57
|
+
# @yield the inner block that calls the provider
|
|
58
|
+
# @return [Object] the provider response
|
|
59
|
+
def invoke(provider, request)
|
|
60
|
+
inner = -> { yield }
|
|
61
|
+
|
|
62
|
+
chain = inner
|
|
63
|
+
@entries.reverse_each do |entry|
|
|
64
|
+
instance = entry[:klass].new(**entry[:options])
|
|
65
|
+
current = chain
|
|
66
|
+
chain = -> { instance.around_request(provider, request) { current.call } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
chain.call
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def resolve(middleware)
|
|
75
|
+
case middleware
|
|
76
|
+
when Symbol
|
|
77
|
+
name = KNOWN_MIDDLEWARES[middleware]
|
|
78
|
+
raise ArgumentError, "Unknown middleware: #{middleware.inspect}" unless name
|
|
79
|
+
|
|
80
|
+
name.split("::").reduce(Object) { |mod, k| mod.const_get(k) }
|
|
81
|
+
when Class
|
|
82
|
+
unless middleware < Base
|
|
83
|
+
raise ArgumentError, "#{middleware} is not a Middleware::Base subclass"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
middleware
|
|
87
|
+
else
|
|
88
|
+
raise ArgumentError, "Expected a Symbol or Class, got #{middleware.class}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Middleware
|
|
6
|
+
# Retries LLM provider calls on transient failures.
|
|
7
|
+
#
|
|
8
|
+
# Retries on:
|
|
9
|
+
# - {Ask::RateLimitError} with exponential backoff + jitter
|
|
10
|
+
# - {Ask::ServerError} (5xx) with exponential backoff
|
|
11
|
+
#
|
|
12
|
+
# Does NOT retry on:
|
|
13
|
+
# - {Ask::Unauthorized} — credentials are wrong, retrying won't help
|
|
14
|
+
# - {Ask::InvalidCredential} — same
|
|
15
|
+
# - {Ask::ModelNotFound} — model doesn't exist
|
|
16
|
+
# - {Ask::ConfigurationError} — user config issue
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# pipeline.use :retry_on_failure, max_retries: 5
|
|
20
|
+
class RetryOnFailure < Base
|
|
21
|
+
DEFAULT_MAX_RETRIES = 3
|
|
22
|
+
|
|
23
|
+
def initialize(max_retries: DEFAULT_MAX_RETRIES)
|
|
24
|
+
@max_retries = max_retries
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def around_request(provider, request)
|
|
28
|
+
last_error = nil
|
|
29
|
+
|
|
30
|
+
@max_retries.times do |attempt|
|
|
31
|
+
begin
|
|
32
|
+
return yield
|
|
33
|
+
rescue Ask::RateLimitError => e
|
|
34
|
+
raise if attempt >= @max_retries - 1
|
|
35
|
+
|
|
36
|
+
delay = e.retry_after || compute_backoff(attempt)
|
|
37
|
+
sleep(delay)
|
|
38
|
+
last_error = e
|
|
39
|
+
rescue Ask::ServerError, Ask::ServiceUnavailable => e
|
|
40
|
+
raise if attempt >= @max_retries - 1
|
|
41
|
+
|
|
42
|
+
sleep(compute_backoff(attempt))
|
|
43
|
+
last_error = e
|
|
44
|
+
rescue Ask::Unauthorized, Ask::InvalidCredential,
|
|
45
|
+
Ask::ModelNotFound, Ask::ConfigurationError
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raise last_error if last_error
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def compute_backoff(attempt)
|
|
56
|
+
(2**attempt) + rand(0.0..1.0)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module StreamTransforms
|
|
6
|
+
# Base class for stream transforms that process {Ask::Chunk}s.
|
|
7
|
+
#
|
|
8
|
+
# Each transform receives every chunk from the LLM stream and can
|
|
9
|
+
# modify, filter, or buffer it. To emit zero or more transformed chunks,
|
|
10
|
+
# yield to the provided block.
|
|
11
|
+
#
|
|
12
|
+
# @example A simple pass-through transform
|
|
13
|
+
# class NoOp < Base
|
|
14
|
+
# def call(chunk, &block)
|
|
15
|
+
# yield chunk
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example A transform that drops thinking-only chunks
|
|
20
|
+
# class DropThinking < Base
|
|
21
|
+
# def call(chunk, &block)
|
|
22
|
+
# yield chunk unless chunk.thinking? && chunk.content.to_s.empty?
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
class Base
|
|
26
|
+
# Process a single chunk from the LLM stream.
|
|
27
|
+
#
|
|
28
|
+
# @param chunk [Ask::Chunk] the raw chunk from the LLM provider
|
|
29
|
+
# @yield [Ask::Chunk] zero or more transformed chunks
|
|
30
|
+
def call(chunk, &block)
|
|
31
|
+
yield chunk
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Called once when the stream finishes, giving buffering transforms
|
|
35
|
+
# a chance to flush any remaining state. The default is a no-op.
|
|
36
|
+
#
|
|
37
|
+
# @yield [Ask::Chunk] final chunks to emit
|
|
38
|
+
def finish(&block)
|
|
39
|
+
# no-op
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
module StreamTransforms
|
|
8
|
+
# Attempts to parse JSON from a streaming LLM text response.
|
|
9
|
+
#
|
|
10
|
+
# When the LLM is instructed to return JSON but structured output
|
|
11
|
+
# (via schema) is not used, the model may emit text that happens
|
|
12
|
+
# to be valid JSON. This transform buffers the text, attempts a
|
|
13
|
+
# JSON parse on each addition, and emits a special chunk with
|
|
14
|
+
# the parsed data when valid JSON is found.
|
|
15
|
+
#
|
|
16
|
+
# This is useful for fallback scenarios where you want structured
|
|
17
|
+
# data without requiring native structured output support from the
|
|
18
|
+
# provider.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# pipeline.use :extract_json
|
|
22
|
+
#
|
|
23
|
+
# # The final chunk will have chunk.extracted_json if JSON was parsed.
|
|
24
|
+
class ExtractJson < Base
|
|
25
|
+
def initialize
|
|
26
|
+
@buffer = +""
|
|
27
|
+
@parsed = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(chunk, &block)
|
|
31
|
+
if chunk.content.to_s.strip.length > 0
|
|
32
|
+
@buffer << chunk.content
|
|
33
|
+
|
|
34
|
+
# Attempt to parse the accumulated buffer as JSON
|
|
35
|
+
begin
|
|
36
|
+
@parsed = JSON.parse(@buffer)
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
# Not valid JSON yet — keep buffering
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Pass the original chunk through
|
|
43
|
+
yield chunk
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash, nil] the parsed JSON data, if the full response was valid JSON
|
|
47
|
+
def extracted_json
|
|
48
|
+
@parsed
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] whether the accumulated response was valid JSON
|
|
52
|
+
def json?
|
|
53
|
+
!@parsed.nil?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module StreamTransforms
|
|
6
|
+
# A composable chain of {Base} transforms applied to LLM stream chunks.
|
|
7
|
+
#
|
|
8
|
+
# Transforms are applied in order — the first registered transform sees
|
|
9
|
+
# each chunk first. Each transform may yield zero, one, or many chunks
|
|
10
|
+
# downstream.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# pipeline = Pipeline.new
|
|
14
|
+
# pipeline.use :thinking_separator
|
|
15
|
+
# pipeline.use :text_buffer, min_size: 100
|
|
16
|
+
#
|
|
17
|
+
# wrapped_block = pipeline.wrap { |chunk| handle_chunk(chunk) }
|
|
18
|
+
# stream.each { |chunk| wrapped_block.call(chunk) }
|
|
19
|
+
# pipeline.flush { |chunk| handle_chunk(chunk) }
|
|
20
|
+
class Pipeline
|
|
21
|
+
KNOWN_TRANSFORMS = {
|
|
22
|
+
thinking_separator: "Ask::Agent::StreamTransforms::ThinkingSeparator",
|
|
23
|
+
text_buffer: "Ask::Agent::StreamTransforms::TextBuffer",
|
|
24
|
+
extract_json: "Ask::Agent::StreamTransforms::ExtractJson"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@transforms = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Register a transform in the chain.
|
|
32
|
+
#
|
|
33
|
+
# @param transform [Symbol, Class] a symbol from KNOWN_TRANSFORMS or a StreamTransforms::Base subclass
|
|
34
|
+
# @param options [Hash] keyword arguments passed to the transform's constructor
|
|
35
|
+
def use(transform, **options)
|
|
36
|
+
klass = resolve(transform)
|
|
37
|
+
@transforms << klass.new(**options)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] whether any transform has been registered
|
|
42
|
+
def configured?
|
|
43
|
+
@transforms.any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Iterate over the configured transform instances.
|
|
47
|
+
def each(&block)
|
|
48
|
+
@transforms.each(&block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Wrap a chunk-processing block with the transform chain.
|
|
52
|
+
#
|
|
53
|
+
# The returned block accepts raw {Ask::Chunk}s, runs them through
|
|
54
|
+
# each transform in sequence, and yields only to the original block
|
|
55
|
+
# for chunks that survive the chain.
|
|
56
|
+
#
|
|
57
|
+
# @yield [Ask::Chunk] transformed chunks
|
|
58
|
+
# @return [Proc] a wrapped block that accepts raw chunks
|
|
59
|
+
def wrap(&block)
|
|
60
|
+
return block unless configured?
|
|
61
|
+
|
|
62
|
+
chain = block
|
|
63
|
+
@transforms.reverse_each do |transform|
|
|
64
|
+
current = chain
|
|
65
|
+
chain = ->(chunk) { transform.call(chunk) { |c| current.call(c) } }
|
|
66
|
+
end
|
|
67
|
+
chain
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Flush any remaining buffered state from all transforms.
|
|
71
|
+
#
|
|
72
|
+
# Call this once when the stream finishes to ensure buffering
|
|
73
|
+
# transforms (e.g. TextBuffer) emit their final content.
|
|
74
|
+
#
|
|
75
|
+
# @yield [Ask::Chunk] final chunks
|
|
76
|
+
def flush(&block)
|
|
77
|
+
@transforms.each do |transform|
|
|
78
|
+
transform.finish { |c| block.call(c) if block }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def resolve(transform)
|
|
85
|
+
case transform
|
|
86
|
+
when Symbol
|
|
87
|
+
name = KNOWN_TRANSFORMS[transform]
|
|
88
|
+
raise ArgumentError, "Unknown stream transform: #{transform.inspect}" unless name
|
|
89
|
+
|
|
90
|
+
name.split("::").reduce(Object) { |mod, k| mod.const_get(k) }
|
|
91
|
+
when Class
|
|
92
|
+
unless transform < Base
|
|
93
|
+
raise ArgumentError, "#{transform} is not a StreamTransforms::Base subclass"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
transform
|
|
97
|
+
else
|
|
98
|
+
raise ArgumentError, "Expected a Symbol or Class, got #{transform.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module StreamTransforms
|
|
6
|
+
# Buffers rapid text deltas into larger contiguous chunks.
|
|
7
|
+
#
|
|
8
|
+
# LLM streaming often produces many tiny deltas (1–5 characters each),
|
|
9
|
+
# especially over high-latency connections. This transform coalesces
|
|
10
|
+
# them into chunks of at least `min_size` characters, reducing the
|
|
11
|
+
# number of UI updates, log entries, or event emissions.
|
|
12
|
+
#
|
|
13
|
+
# Only text content is buffered. Non-content chunks (tool calls,
|
|
14
|
+
# finish signals, usage data) pass through immediately.
|
|
15
|
+
#
|
|
16
|
+
# @example Buffer until at least 100 characters
|
|
17
|
+
# pipeline.use :text_buffer, min_size: 100
|
|
18
|
+
class TextBuffer < Base
|
|
19
|
+
def initialize(min_size: 50)
|
|
20
|
+
@min_size = min_size
|
|
21
|
+
@buffer = +""
|
|
22
|
+
@pending_usage = nil
|
|
23
|
+
@pending_finish = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(chunk, &block)
|
|
27
|
+
if chunk.content.to_s.strip.length > 0
|
|
28
|
+
@buffer << chunk.content
|
|
29
|
+
|
|
30
|
+
if @buffer.length >= @min_size
|
|
31
|
+
emit_buffer(&block)
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
# Flush buffer before non-content chunks
|
|
35
|
+
emit_buffer(&block)
|
|
36
|
+
|
|
37
|
+
# Track metadata to emit after flush
|
|
38
|
+
if chunk.finish_reason
|
|
39
|
+
@pending_finish = chunk.finish_reason
|
|
40
|
+
end
|
|
41
|
+
if chunk.usage
|
|
42
|
+
@pending_usage = chunk.usage
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Pass through non-content chunks (tool calls, etc.)
|
|
46
|
+
yield chunk
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finish(&block)
|
|
51
|
+
emit_buffer(&block) if @buffer.length > 0
|
|
52
|
+
|
|
53
|
+
# Emit pending metadata if any
|
|
54
|
+
if @pending_finish || @pending_usage
|
|
55
|
+
yield Ask::Chunk.new(
|
|
56
|
+
content: nil,
|
|
57
|
+
tool_calls: nil,
|
|
58
|
+
finish_reason: @pending_finish,
|
|
59
|
+
usage: @pending_usage,
|
|
60
|
+
raw: nil,
|
|
61
|
+
thinking: nil
|
|
62
|
+
)
|
|
63
|
+
@pending_finish = nil
|
|
64
|
+
@pending_usage = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def emit_buffer(&block)
|
|
71
|
+
return if @buffer.empty?
|
|
72
|
+
|
|
73
|
+
yield Ask::Chunk.new(
|
|
74
|
+
content: @buffer.dup,
|
|
75
|
+
tool_calls: nil,
|
|
76
|
+
finish_reason: nil,
|
|
77
|
+
usage: nil,
|
|
78
|
+
raw: nil,
|
|
79
|
+
thinking: nil
|
|
80
|
+
)
|
|
81
|
+
@buffer.clear
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module StreamTransforms
|
|
6
|
+
# Separates thinking/reasoning content from visible text content in the
|
|
7
|
+
# stream. Useful when you want to handle thinking tokens separately
|
|
8
|
+
# (e.g., display them in a collapsible UI section or log them for
|
|
9
|
+
# debugging).
|
|
10
|
+
#
|
|
11
|
+
# Some LLM providers (notably Anthropic) send thinking content as
|
|
12
|
+
# separate chunks with both `content` and `thinking` fields populated.
|
|
13
|
+
# This transform splits those into two chunks: a thinking-only chunk
|
|
14
|
+
# (with the thinking content) and a text-only chunk (with the visible
|
|
15
|
+
# content), so downstream code can handle each independently.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# pipeline.use :thinking_separator
|
|
19
|
+
#
|
|
20
|
+
# # Now chunks arrive as:
|
|
21
|
+
# # chunk.content? && !chunk.thinking? → visible text
|
|
22
|
+
# # chunk.thinking? && !chunk.content? → thinking text
|
|
23
|
+
class ThinkingSeparator < Base
|
|
24
|
+
def call(chunk, &block)
|
|
25
|
+
if chunk.thinking? && chunk.content.to_s.strip.length > 0
|
|
26
|
+
# Split: emit thinking first, then content
|
|
27
|
+
yield Ask::Chunk.new(
|
|
28
|
+
content: nil,
|
|
29
|
+
tool_calls: nil,
|
|
30
|
+
finish_reason: nil,
|
|
31
|
+
usage: nil,
|
|
32
|
+
raw: nil,
|
|
33
|
+
thinking: chunk.thinking
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
yield Ask::Chunk.new(
|
|
37
|
+
content: chunk.content,
|
|
38
|
+
tool_calls: chunk.tool_calls,
|
|
39
|
+
finish_reason: chunk.finish_reason,
|
|
40
|
+
usage: chunk.usage,
|
|
41
|
+
raw: chunk.raw,
|
|
42
|
+
thinking: nil
|
|
43
|
+
)
|
|
44
|
+
elsif chunk.thinking? && chunk.content.to_s.strip.empty?
|
|
45
|
+
# Pure thinking chunk — pass through as-is
|
|
46
|
+
yield chunk
|
|
47
|
+
else
|
|
48
|
+
# No thinking — pass through
|
|
49
|
+
yield chunk
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -24,6 +24,22 @@ module Ask
|
|
|
24
24
|
autoload :AuditLog, "ask/agent/extensions/audit_log"
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
module Middleware
|
|
28
|
+
autoload :Base, "ask/agent/middleware/base"
|
|
29
|
+
autoload :Pipeline, "ask/agent/middleware/pipeline"
|
|
30
|
+
autoload :RetryOnFailure, "ask/agent/middleware/retry_on_failure"
|
|
31
|
+
autoload :LogCalls, "ask/agent/middleware/log_calls"
|
|
32
|
+
autoload :DefaultSettings, "ask/agent/middleware/default_settings"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module StreamTransforms
|
|
36
|
+
autoload :Base, "ask/agent/stream_transforms/base"
|
|
37
|
+
autoload :Pipeline, "ask/agent/stream_transforms/pipeline"
|
|
38
|
+
autoload :ThinkingSeparator, "ask/agent/stream_transforms/thinking_separator"
|
|
39
|
+
autoload :TextBuffer, "ask/agent/stream_transforms/text_buffer"
|
|
40
|
+
autoload :ExtractJson, "ask/agent/stream_transforms/extract_json"
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
class << self
|
|
28
44
|
def configuration
|
|
29
45
|
@configuration ||= Configuration.new
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -144,11 +144,21 @@ files:
|
|
|
144
144
|
- lib/ask/agent/hooks.rb
|
|
145
145
|
- lib/ask/agent/loop.rb
|
|
146
146
|
- lib/ask/agent/meta_agent.rb
|
|
147
|
+
- lib/ask/agent/middleware/base.rb
|
|
148
|
+
- lib/ask/agent/middleware/default_settings.rb
|
|
149
|
+
- lib/ask/agent/middleware/log_calls.rb
|
|
150
|
+
- lib/ask/agent/middleware/pipeline.rb
|
|
151
|
+
- lib/ask/agent/middleware/retry_on_failure.rb
|
|
147
152
|
- lib/ask/agent/persistence/base.rb
|
|
148
153
|
- lib/ask/agent/persistence/in_memory.rb
|
|
149
154
|
- lib/ask/agent/reflector.rb
|
|
150
155
|
- lib/ask/agent/session.rb
|
|
151
156
|
- lib/ask/agent/skills/load_skill_tool.rb
|
|
157
|
+
- lib/ask/agent/stream_transforms/base.rb
|
|
158
|
+
- lib/ask/agent/stream_transforms/extract_json.rb
|
|
159
|
+
- lib/ask/agent/stream_transforms/pipeline.rb
|
|
160
|
+
- lib/ask/agent/stream_transforms/text_buffer.rb
|
|
161
|
+
- lib/ask/agent/stream_transforms/thinking_separator.rb
|
|
152
162
|
- lib/ask/agent/telemetry.rb
|
|
153
163
|
- lib/ask/agent/test.rb
|
|
154
164
|
- lib/ask/agent/tool_abort_controller.rb
|