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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fefebee321ad33107e11e7c783845001e7fde3915929aa3c04530667472b296b
4
- data.tar.gz: f5745683b396c62486b875ee82b27797a6feb20f729582591889e9ae432061f8
3
+ metadata.gz: 8a1f33fb0145ac0a1abf50990d15a6f4aa288a67fe8ec11f478cead3b9401a43
4
+ data.tar.gz: ebaa94902c33f6fe351b97108c6db085096a92ca4206706c8197cba5aaa5d8d5
5
5
  SHA512:
6
- metadata.gz: 78905532187f65c8ba4d055398e8e3e9b9eaac62d02bbf001b934fccaadbd6dbb308677fb24387e6176c67398888dff25a9782279fd36c6ec07ddaa9c928b17b
7
- data.tar.gz: 4dfa92ce9ddac07b363d085edcb6d55b7a493bced85c061d221e835c157598b87e9352de710b2a4fba495b01eea6915b82bbd6940b2e71463e5779ff6340c9d4
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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.4.7"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
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.7
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