ask-agent 0.3.1 → 0.4.1

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: 26c124febbf2652616754dd539cc8df5d24afe5fbe8c03381f760598578a051f
4
- data.tar.gz: 50d87794d60901a85da426f50a1a619807faf96bbe1fedb58145dd2d5d7fbeb1
3
+ metadata.gz: 16f5dbe983536b338fe5231296ff64a48777d230d782cb81ba14d11a8505ac91
4
+ data.tar.gz: 4d5c8f49e66ce01dd41807c298c86f9ff9b0547c0636d18a61b38567a56e4ca1
5
5
  SHA512:
6
- metadata.gz: 5927098dcbd1fe84faa0cf2a7cddbc63e544a7113e47c61f972044e598053758417edce0e63d9a55d7922fbbc65ad9f552ef5c3fb1c07ac71ba1b718d1cbb815
7
- data.tar.gz: 0436ac54f1dcd57ac06706d7e6a8ad5030574341170e0c7e99ca174d3dd94341651142c78bf5d138be1f99a978d71eb21f03a57df1ef4b6cb038ebb22778a637
6
+ metadata.gz: d03ce2a1ec7461b5087e96014ac5b21689ce5fb41a7d8a161bfe5505839930fbf0e6fdfaad129d506ba3fbead429b52a12b854ab5c06f173aa6aae3ef2d9e4ad
7
+ data.tar.gz: 76019686018d278a9ac50bc3259775424be29208b8e345e12574a130b8bcdca47a242172ddeff2a59817780418101d71051eb90130b6ef3f406a895e3e89bbab
data/CHANGELOG.md CHANGED
@@ -1,3 +1,39 @@
1
+ ## [0.4.1] — 2026-07-18
2
+
3
+ ### Fixed
4
+
5
+ - **`Chat#provider_config` now falls back to base provider name for credential lookup** — When a provider slug has a compound name like `opencode_go`, the method first tries `Ask::Auth.resolve(:opencode_go_api_key)` and if that returns nil, falls back to `Ask::Auth.resolve(:opencode_api_key)`. This lets users store credentials under the canonical provider name (e.g., `opencode.api_key` in Rails credentials) while using a specific variant provider (e.g., `opencode_go`).
6
+
7
+ ## [0.4.0] — 2026-07-17
8
+
9
+ ### Added
10
+
11
+ - **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.
12
+
13
+ ```ruby
14
+ require "ask/agent/test"
15
+
16
+ class MyAgentTest < Minitest::Test
17
+ include Ask::Agent::Test::Assertions
18
+
19
+ def setup
20
+ @session = Ask::Agent::Session.new(model: "gpt-4o", tools: [my_tool])
21
+ @session.test_mode
22
+ end
23
+
24
+ def test_calls_search_tool
25
+ @session.stub_tool_call("search", query: "weather")
26
+ @session.stub_text("Sunny")
27
+ @session.run("What's the weather?")
28
+ assert_called_tool "search"
29
+ assert_final_response /Sunny/
30
+ assert_no_unused_stubs
31
+ end
32
+ end
33
+ ```
34
+
35
+ Assertions: `assert_called_tool`, `refute_called_tool`, `assert_tool_order`, `assert_final_response`, `assert_no_unused_stubs`.
36
+
1
37
  ## [0.3.1] — 2026-07-17
2
38
 
3
39
  ### Added
@@ -51,7 +51,7 @@ module Ask
51
51
 
52
52
  result = chat_with_retry(stream, calls_acc, &block)
53
53
 
54
- response_msg = if stream
54
+ response_msg = if result.respond_to?(:chunks)
55
55
  build_stream_response(result, calls_acc)
56
56
  else
57
57
  build_response(result)
@@ -104,10 +104,12 @@ module Ask
104
104
  @messages.clear
105
105
  end
106
106
 
107
+ attr_writer :test_provider
108
+
107
109
  private
108
110
 
109
111
  def provider
110
- @provider ||= build_provider
112
+ @test_provider || @provider ||= build_provider
111
113
  end
112
114
 
113
115
  def build_provider
@@ -118,10 +120,18 @@ module Ask
118
120
 
119
121
  def provider_config(slug)
120
122
  key = Ask::Auth.resolve(:"#{slug}_api_key") rescue nil
121
- base = Ask::Auth.resolve(:"#{slug}_api_base") rescue nil
123
+ # For compound provider slugs (e.g., opencode_go), fall back to the
124
+ # base name (e.g., opencode) for credential lookup. This lets users
125
+ # store credentials under the canonical provider name while using a
126
+ # specific variant provider.
127
+ if key.nil? && slug.to_s.include?("_")
128
+ base = slug.to_s.split("_").first
129
+ key = Ask::Auth.resolve(:"#{base}_api_key") rescue nil
130
+ end
131
+ base_url = Ask::Auth.resolve(:"#{slug}_api_base") rescue nil
122
132
  config = { api_key: key }
123
133
  config[:"#{slug}_api_key"] = key
124
- config[:"#{slug}_api_base"] = base if base
134
+ config[:"#{slug}_api_base"] = base_url if base_url
125
135
  Ask::LLM::Config.new(config)
126
136
  end
127
137
 
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.1"
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.1
4
+ version: 0.4.1
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