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 +4 -4
- data/CHANGELOG.md +40 -0
- data/lib/ask/agent/chat.rb +40 -23
- data/lib/ask/agent/test.rb +162 -0
- data/lib/ask/agent/tool_executor.rb +9 -4
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +3 -0
- 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: 3d59df39cdd8e4f3aef8b71f9c63fee0c571735d03f409667d3c3b9fed964e59
|
|
4
|
+
data.tar.gz: e6546221a9c0d609d6b0bcaa4295d6c285ebf6e9e2750e2ee69715cda257017c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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)
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
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.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
|