ask-agent 0.3.0 → 0.4.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: 4812b86203eb5a12eb03138de1e50fe8f0a80532f545bb683dcf051f2729c088
4
- data.tar.gz: ba9e999b2aedae3e9c0f55067a35e3d3f29f828717a8f7129c0e0037bd518a33
3
+ metadata.gz: 3d59df39cdd8e4f3aef8b71f9c63fee0c571735d03f409667d3c3b9fed964e59
4
+ data.tar.gz: e6546221a9c0d609d6b0bcaa4295d6c285ebf6e9e2750e2ee69715cda257017c
5
5
  SHA512:
6
- metadata.gz: a5b049b05acf1a82334a2169a708d60a6511db2d790ec5db3f30b9b4775a561eb204b8fdb209b60d095edeba43bd27f1572a909d78118eba9f295943625da370
7
- data.tar.gz: 54ba99bbacd6152c52196f276f28559d2a13e09b048338c4c5198e9ab01c8f2cc9e5eb57c2a957b1ba6dea4410726c3eba644104b66d767729e908a10c366133
6
+ metadata.gz: f0569cd462c103a755d1a05606150ef229504cc090a8b0dfbbd1bfb79dba8f67cac82d1a1aaa1039ce75294b3aaf55d9f68478df5de1f878b197f5bc6d12335a
7
+ data.tar.gz: 970cabb7492914f0874a76c04ed72f04d9882020a900db3a8d07ca043097432a4e25bfa2c46e3678d7db67cd98f8d25a03d97c64dab1157a2278f97bb23132cd
data/CHANGELOG.md CHANGED
@@ -1,3 +1,43 @@
1
+ ## [0.4.0] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **Agent testing framework** — `Ask::Agent::Test` provides deterministic agent behavior tests without calling real LLMs. Stub tool calls and text responses, assert which tools were called, in what order, and verify the final response. No flaky tests, no API keys, no cost.
6
+
7
+ ```ruby
8
+ require "ask/agent/test"
9
+
10
+ class MyAgentTest < Minitest::Test
11
+ include Ask::Agent::Test::Assertions
12
+
13
+ def setup
14
+ @session = Ask::Agent::Session.new(model: "gpt-4o", tools: [my_tool])
15
+ @session.test_mode
16
+ end
17
+
18
+ def test_calls_search_tool
19
+ @session.stub_tool_call("search", query: "weather")
20
+ @session.stub_text("Sunny")
21
+ @session.run("What's the weather?")
22
+ assert_called_tool "search"
23
+ assert_final_response /Sunny/
24
+ assert_no_unused_stubs
25
+ end
26
+ end
27
+ ```
28
+
29
+ Assertions: `assert_called_tool`, `refute_called_tool`, `assert_tool_order`, `assert_final_response`, `assert_no_unused_stubs`.
30
+
31
+ ## [0.3.1] — 2026-07-17
32
+
33
+ ### Added
34
+
35
+ - **Rate-limit aware retry in Chat** — `Chat#ask` retries up to 3 times on `RateLimitError`, using `retry_after` from the error when available, otherwise exponential backoff with jitter.
36
+
37
+ ### Fixed
38
+
39
+ - **`retryable_error_name?` in ToolExecutor** — fixed duplicate `Ask::RateLimitError` and non-existent `Ask::ServiceUnavailableError`. Now uses class hierarchy matching so subclasses are also retried. (Backport from LiteLLM error classification.)
40
+
1
41
  ## [0.3.0] — 2026-07-17
2
42
 
3
43
  ### Added
@@ -49,29 +49,9 @@ module Ask
49
49
  provider_schema = @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema
50
50
  provider_params = @extra_params || {}
51
51
 
52
- result = provider.chat(
53
- @messages.map(&:to_h),
54
- model: provider_model,
55
- tools: provider_tools,
56
- temperature: provider_temp,
57
- stream: stream,
58
- schema: provider_schema,
59
- **provider_params
60
- ) do |raw_chunk|
61
- next unless block_given?
62
-
63
- accumulate_tool_calls(raw_chunk, calls_acc)
64
-
65
- yield ChatChunk.new(
66
- content: raw_chunk.content,
67
- tool_calls: build_current_tool_calls(calls_acc),
68
- thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil,
69
- input_tokens: nil,
70
- output_tokens: nil
71
- )
72
- end
52
+ result = chat_with_retry(stream, calls_acc, &block)
73
53
 
74
- response_msg = if stream
54
+ response_msg = if result.respond_to?(:chunks)
75
55
  build_stream_response(result, calls_acc)
76
56
  else
77
57
  build_response(result)
@@ -124,10 +104,12 @@ module Ask
124
104
  @messages.clear
125
105
  end
126
106
 
107
+ attr_writer :test_provider
108
+
127
109
  private
128
110
 
129
111
  def provider
130
- @provider ||= build_provider
112
+ @test_provider || @provider ||= build_provider
131
113
  end
132
114
 
133
115
  def build_provider
@@ -234,6 +216,41 @@ module Ask
234
216
  nil
235
217
  end
236
218
 
219
+ MAX_CHAT_RETRIES = 3
220
+
221
+ def chat_with_retry(stream, calls_acc, &block)
222
+ MAX_CHAT_RETRIES.times do |attempt|
223
+ begin
224
+ return provider.chat(
225
+ @messages.map(&:to_h),
226
+ model: @model_id,
227
+ tools: @tools.map { |t| Ask::ToolDef.from_tool(t) },
228
+ temperature: @temperature,
229
+ stream: stream,
230
+ schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
231
+ **(@extra_params || {})
232
+ ) do |raw_chunk|
233
+ next unless block
234
+
235
+ accumulate_tool_calls(raw_chunk, calls_acc)
236
+
237
+ block.call(ChatChunk.new(
238
+ content: raw_chunk.content,
239
+ tool_calls: build_current_tool_calls(calls_acc),
240
+ thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil,
241
+ input_tokens: nil,
242
+ output_tokens: nil
243
+ ))
244
+ end
245
+ rescue Ask::RateLimitError => e
246
+ raise if attempt >= MAX_CHAT_RETRIES - 1
247
+
248
+ delay = e.retry_after || ((2 ** attempt) + rand(0.0..1.0))
249
+ sleep(delay)
250
+ end
251
+ end
252
+ end
253
+
237
254
  def emit_instrumentation(stream, response_msg)
238
255
  return unless defined?(Ask::Instrumentation)
239
256
 
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Test
6
+ # Holds stubs, tracks called tools and final response for a test session.
7
+ class Mode
8
+ attr_reader :stubs, :called_tools, :final_response
9
+
10
+ def initialize
11
+ @stubs = []
12
+ @called_tools = []
13
+ @final_response = nil
14
+ end
15
+
16
+ def stub_tool_call(name, arguments = {})
17
+ @stubs << { type: :tool_call, name: name.to_s, arguments: arguments }
18
+ end
19
+
20
+ def stub_text(content)
21
+ @stubs << { type: :text, content: content }
22
+ end
23
+
24
+ def record_tool_call(name)
25
+ @called_tools << name
26
+ end
27
+
28
+ def record_final_response(response)
29
+ @final_response = response
30
+ end
31
+
32
+ def unused_stubs
33
+ @stubs
34
+ end
35
+ end
36
+
37
+ # Stub provider that returns canned responses instead of calling an LLM.
38
+ class StubProvider
39
+ def initialize(mode)
40
+ @mode = mode
41
+ end
42
+
43
+ def chat(messages, model: nil, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
44
+ stub = @mode.stubs.shift or raise "No more stubs available. Unconsumed messages: #{messages.length}"
45
+
46
+ case stub[:type]
47
+ when :tool_call
48
+ Ask::Message.new(
49
+ role: :assistant,
50
+ content: nil,
51
+ tool_calls: [{
52
+ id: "call_test_#{SecureRandom.hex(4)}",
53
+ type: "function",
54
+ name: stub[:name],
55
+ arguments: JSON.generate(stub[:arguments])
56
+ }],
57
+ metadata: { input_tokens: 10, output_tokens: 5 }
58
+ )
59
+ when :text
60
+ Ask::Message.new(
61
+ role: :assistant,
62
+ content: stub[:content],
63
+ metadata: { input_tokens: 10, output_tokens: 5 }
64
+ )
65
+ else
66
+ raise "Unknown stub type: #{stub[:type].inspect}"
67
+ end
68
+ end
69
+
70
+ def headers
71
+ {}
72
+ end
73
+ end
74
+
75
+ # Methods added to Session when in test mode.
76
+ module SessionOverride
77
+ def test_mode
78
+ return @test_mode if @test_mode
79
+
80
+ @test_mode = Mode.new
81
+ @chat&.test_provider = StubProvider.new(@test_mode) if @chat
82
+ @test_mode
83
+ end
84
+
85
+ def stub_tool_call(name, arguments = {})
86
+ test_mode.stub_tool_call(name, arguments)
87
+ end
88
+
89
+ def stub_text(content)
90
+ test_mode.stub_text(content)
91
+ end
92
+
93
+ def called_tool?(name)
94
+ test_mode.called_tools.include?(name.to_s)
95
+ end
96
+
97
+ private
98
+
99
+ def build_chat(model, system_prompt, tools, **chat_options)
100
+ if @test_mode
101
+ chat = Ask::Agent::Chat.new(model: model, tools: tools, **chat_options)
102
+ chat.with_instructions(system_prompt) if system_prompt
103
+ chat.test_provider = StubProvider.new(@test_mode)
104
+ chat
105
+ else
106
+ super
107
+ end
108
+ end
109
+
110
+ def emit(event)
111
+ super
112
+ return unless @test_mode
113
+
114
+ case event
115
+ when Ask::Agent::Events::ToolExecutionStart
116
+ @test_mode.record_tool_call(event.name)
117
+ when Ask::Agent::Events::SessionEnd
118
+ @test_mode.record_final_response(event.result)
119
+ end
120
+ end
121
+
122
+ public :emit
123
+ end
124
+
125
+ # Test assertions to include in Minitest test classes.
126
+ module Assertions
127
+ def session
128
+ @session || raise("@session not set — define it in setup")
129
+ end
130
+
131
+ def assert_called_tool(name, msg = nil)
132
+ assert session.called_tool?(name),
133
+ msg || "Expected tool #{name.inspect} to be called. Called: #{session.test_mode.called_tools.inspect}"
134
+ end
135
+
136
+ def refute_called_tool(name, msg = nil)
137
+ refute session.called_tool?(name),
138
+ msg || "Expected tool #{name.inspect} not to be called. Called: #{session.test_mode.called_tools.inspect}"
139
+ end
140
+
141
+ def assert_tool_order(names, msg = nil)
142
+ expected = names.map(&:to_s)
143
+ called = session.test_mode.called_tools
144
+ matched = called.first(expected.length)
145
+ assert_equal expected, matched,
146
+ msg || "Tool call order mismatch. Expected: #{expected.inspect}, Got: #{matched.inspect}"
147
+ end
148
+
149
+ def assert_final_response(match, msg = nil)
150
+ assert_match match, session.test_mode.final_response.to_s, msg
151
+ end
152
+
153
+ def assert_no_unused_stubs(msg = nil)
154
+ assert_empty session.test_mode.unused_stubs,
155
+ msg || "Unused stubs: #{session.test_mode.unused_stubs.inspect}"
156
+ end
157
+ end
158
+
159
+ Ask::Agent::Session.prepend(SessionOverride)
160
+ end
161
+ end
162
+ end
@@ -182,10 +182,15 @@ module Ask
182
182
  end
183
183
 
184
184
  def retryable_error_name?(error_name)
185
- retryable = %w[Timeout::Error Errno::ETIMEDOUT
186
- Ask::RateLimitError Ask::ServerError
187
- Ask::RateLimitError Ask::ServiceUnavailableError]
188
- retryable.include?(error_name)
185
+ return false unless error_name
186
+
187
+ klass = Object.const_get(error_name) rescue nil
188
+ return false unless klass
189
+
190
+ klass <= Ask::RateLimitError ||
191
+ klass <= Ask::ServerError ||
192
+ klass <= Ask::ServiceUnavailable ||
193
+ %w[Timeout::Error Errno::ETIMEDOUT].include?(error_name)
189
194
  end
190
195
 
191
196
  def critical_error?(error_class_name)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -56,3 +56,6 @@ require_relative "agent/configuration"
56
56
  require_relative "agent/meta_agent"
57
57
  require_relative "agent/persistence/base"
58
58
  require_relative "agent/persistence/in_memory"
59
+
60
+ # Test helpers (loaded on demand)
61
+ autoload :Test, "ask/agent/test"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -149,6 +149,7 @@ files:
149
149
  - lib/ask/agent/reflector.rb
150
150
  - lib/ask/agent/session.rb
151
151
  - lib/ask/agent/telemetry.rb
152
+ - lib/ask/agent/test.rb
152
153
  - lib/ask/agent/tool_abort_controller.rb
153
154
  - lib/ask/agent/tool_executor.rb
154
155
  - lib/ask/agent/version.rb