ask-agent 0.2.2 → 0.3.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: 38ad7fd8e88fbb94137ceff0dacd127b598e3487c69b1b58feaab1299c3e0d35
4
- data.tar.gz: 6b86830a8ef5547305efdb5d8aa36b38de9d4ea0b1ce55c6c207668575ff554c
3
+ metadata.gz: 26c124febbf2652616754dd539cc8df5d24afe5fbe8c03381f760598578a051f
4
+ data.tar.gz: 50d87794d60901a85da426f50a1a619807faf96bbe1fedb58145dd2d5d7fbeb1
5
5
  SHA512:
6
- metadata.gz: c9e4653c5d70595143daf009735193f984e7f972bc075658508b8baf26d3fcdb044fa251c50d4de95593b268ec2715334c67bc2e7a7578fee419f2da4eab8cd0
7
- data.tar.gz: e375fe521571910a21c00dfe169b506fc617e0c46bc235e108369c197ee866c05e42813905dcbc9445c898d8143d1b826666dd6593816feefa5fde7b8f539a8c
6
+ metadata.gz: 5927098dcbd1fe84faa0cf2a7cddbc63e544a7113e47c61f972044e598053758417edce0e63d9a55d7922fbbc65ad9f552ef5c3fb1c07ac71ba1b718d1cbb815
7
+ data.tar.gz: 0436ac54f1dcd57ac06706d7e6a8ad5030574341170e0c7e99ca174d3dd94341651142c78bf5d138be1f99a978d71eb21f03a57df1ef4b6cb038ebb22778a637
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [0.3.1] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **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.
6
+
7
+ ### Fixed
8
+
9
+ - **`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.)
10
+
11
+ ## [0.3.0] — 2026-07-17
12
+
13
+ ### Added
14
+
15
+ - **Token and cost tracking** — `ResponseMessage` and `ChatChunk` now carry `input_tokens`, `output_tokens`, and `cost` fields. Token counts are extracted from provider responses and streaming chunks.
16
+ - **Instrumentation events** — `Chat#ask` emits `chat.ask` and `chat.stream.ask` events via `Ask::Instrumentation`, unlocking the full monitoring pipeline (ask-agent → ask-instrumentation → ask-monitoring).
17
+ - **Cost in agent events** — `SessionEnd` and `TurnEnd` events now include `input_tokens`, `output_tokens`, and `cost` fields, accumulated across all turns in the session.
18
+ - **Cumulative session costs** — `Session` tracks `total_input_tokens`, `total_output_tokens`, and `total_cost` across all turns and reflection rounds.
19
+
20
+ ### Changed
21
+
22
+ - **Dependency added** — `ask-instrumentation >= 0.1` added to gemspec. Instrumentation is optional (emission is wrapped in `defined?` check).
23
+ - **Gemfile** — now uses local path resolution for sibling ask-* gems during development.
24
+
1
25
  ## [0.2.1] - 2026-06-25
2
26
 
3
27
  ### Changed
@@ -3,42 +3,27 @@
3
3
  module Ask
4
4
  module Agent
5
5
  # Response message returned by {Chat#ask}.
6
- # Presents a message-like response interface for ask-agent internal use.
7
- ResponseMessage = Data.define(:content, :tool_calls, :thinking) do
6
+ # Includes token counts and cost when available from the provider.
7
+ ResponseMessage = Data.define(:content, :tool_calls, :thinking, :input_tokens, :output_tokens, :cost) do
8
8
  def tool_call? = !tool_calls.empty?
9
9
  def to_s = content.to_s
10
10
  end
11
11
 
12
- # Tool call data used in {ResponseMessage} and {ChatChunk}.
13
12
  ToolCallInfo = Data.define(:id, :name, :arguments)
14
13
 
15
- # Chunk yielded during streaming from {Chat#ask}.
16
- ChatChunk = Data.define(:content, :tool_calls, :thinking) do
14
+ ChatChunk = Data.define(:content, :tool_calls, :thinking, :input_tokens, :output_tokens) do
17
15
  def tool_call? = !tool_calls.empty?
18
16
  end
19
17
 
20
- # Thin wrapper around {Ask::Provider} + an internal message array that
21
- # presents a Chat-like API for ask-agent internal use.
22
- #
23
- # Manages conversation history, resolves the correct provider/model,
24
- # handles streaming chunk accumulation, and normalises tool call
25
- # formats between Ask::Provider (Array of Hashes) and ask-agent
26
- # internal usage (Hash of { id => ToolCallInfo }).
27
18
  class Chat
28
- # @return [String] model ID (e.g. "gpt-4o")
29
- attr_reader :model_id
30
- # @return [String] model ID (e.g. "gpt-4o")
19
+ attr_reader :model_id
20
+
31
21
  def model
32
22
  @model_id
33
23
  end
34
- # @return [Array<Ask::Message>] all messages in the conversation
24
+
35
25
  attr_reader :messages
36
26
 
37
- # @param model [String, #ask] model ID or chat-like object
38
- # @param tools [Array<Ask::Tool>] tool instances available to the chat
39
- # @param temperature [Float, nil] sampling temperature
40
- # @param schema [Ask::Schema, Hash, nil] structured output schema
41
- # @param provider [String, Symbol, nil] provider slug (overrides catalog lookup)
42
27
  def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, **)
43
28
  @model_id = model.respond_to?(:id) ? model.id : model.to_s
44
29
  @model_info = Ask::ModelCatalog.find(@model_id)
@@ -50,40 +35,21 @@ module Ask
50
35
  @provider = nil
51
36
  end
52
37
 
53
- # Send a user message and get a completion response.
54
- #
55
- # @param message [String, nil] user message text
56
- # @yield [ChatChunk] streaming chunks (only when a block is given)
57
- # @return [ResponseMessage] the assistant's response
58
38
  def ask(message = nil, &block)
59
39
  @messages << Ask::Message.new(role: :user, content: message.to_s) if message
60
40
 
61
41
  stream = block_given?
62
42
  tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
63
43
 
64
- # Accumulator for tool calls during streaming (keyed by index)
65
44
  calls_acc = {}
66
45
 
67
- result = provider.chat( @messages.map(&:to_h),
68
- model: @model_id,
69
- tools: tool_defs,
70
- temperature: @temperature,
71
- stream: stream,
72
- schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
73
- **(@extra_params || {})
74
- ) do |raw_chunk|
75
- next unless block_given?
76
-
77
- # Accumulate tool calls by index during streaming
78
- accumulate_tool_calls(raw_chunk, calls_acc)
79
-
80
- # Yield adapted chunk with current tool call state
81
- yield ChatChunk.new(
82
- content: raw_chunk.content,
83
- tool_calls: build_current_tool_calls(calls_acc),
84
- thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil
85
- )
86
- end
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)
87
53
 
88
54
  response_msg = if stream
89
55
  build_stream_response(result, calls_acc)
@@ -91,24 +57,24 @@ module Ask
91
57
  build_response(result)
92
58
  end
93
59
 
94
- # Store assistant response in conversation history
95
60
  @messages << Ask::Message.new(
96
61
  role: :assistant,
97
62
  content: response_msg.content,
98
63
  tool_calls: response_msg.tool_calls&.values&.map { |tc|
99
64
  { id: tc.id, type: "function", name: tc.name, arguments: tc.arguments }
100
- }
65
+ },
66
+ metadata: {
67
+ input_tokens: response_msg.input_tokens,
68
+ output_tokens: response_msg.output_tokens,
69
+ cost: response_msg.cost
70
+ }.compact
101
71
  )
102
72
 
73
+ emit_instrumentation(stream, response_msg)
74
+
103
75
  response_msg
104
76
  end
105
77
 
106
- # Add a message to the conversation history.
107
- #
108
- # @param role [Symbol] :system, :user, :assistant, :tool
109
- # @param content [String, nil] message content
110
- # @param tool_call_id [String, nil] tool call ID (for tool results)
111
- # @param tool_calls [Array<Hash>, nil] tool call invocations
112
78
  def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil)
113
79
  @messages << Ask::Message.new(
114
80
  role: role,
@@ -118,46 +84,28 @@ module Ask
118
84
  )
119
85
  end
120
86
 
121
- # Set or replace the system prompt.
122
- #
123
- # @param prompt [String] system instructions
124
- # @return [self]
125
87
  def with_instructions(prompt)
126
88
  @messages.reject! { |m| m.role == :system }
127
89
  @messages.unshift(Ask::Message.new(role: :system, content: prompt))
128
90
  self
129
91
  end
130
92
 
131
- # Set the structured output schema and return self.
132
- #
133
- # @param schema [Ask::Schema, Hash] structured output schema
134
- # @return [self]
135
- # Set additional parameters for the provider call and return self.
136
- #
137
- # @param params [Hash] extra parameters passed to the provider
138
- # @return [self]
139
- def with_params(**params)
140
- @extra_params = (@extra_params || {}).merge(params)
141
- self
142
- end
93
+ def with_params(**params)
94
+ @extra_params = (@extra_params || {}).merge(params)
95
+ self
96
+ end
143
97
 
144
- # Set additional parameters forwarded to the provider call.
145
- # @param params [Hash] extra keyword arguments for the provider
146
- # @return [self]
147
- def with_schema(schema)
98
+ def with_schema(schema)
148
99
  @schema = schema.respond_to?(:to_json_schema) ? schema.to_json_schema : schema
149
100
  self
150
101
  end
151
102
 
152
- # Clear all messages from the conversation.
153
103
  def reset_messages!
154
104
  @messages.clear
155
105
  end
156
106
 
157
107
  private
158
108
 
159
- # Resolve model info from the catalog.
160
- # Lazily resolve and instantiate the LLM provider.
161
109
  def provider
162
110
  @provider ||= build_provider
163
111
  end
@@ -177,7 +125,6 @@ def with_schema(schema)
177
125
  Ask::LLM::Config.new(config)
178
126
  end
179
127
 
180
- # Accumulate partial tool calls from streaming chunks.
181
128
  def accumulate_tool_calls(raw_chunk, calls_acc)
182
129
  return unless raw_chunk.tool_call?
183
130
 
@@ -190,7 +137,6 @@ def with_schema(schema)
190
137
  end
191
138
  end
192
139
 
193
- # Build current snapshot of tool calls from accumulator.
194
140
  def build_current_tool_calls(calls_acc)
195
141
  hash = {}
196
142
  calls_acc.each_value do |tc_data|
@@ -204,7 +150,6 @@ def with_schema(schema)
204
150
  hash
205
151
  end
206
152
 
207
- # Convert Ask::Provider tool_calls (Array of Hashes) to Hash.
208
153
  def build_tool_call_hash(raw_calls)
209
154
  hash = {}
210
155
  raw_calls.each do |tc|
@@ -219,21 +164,111 @@ def with_schema(schema)
219
164
  hash
220
165
  end
221
166
 
222
- # Build response from streaming result.
223
167
  def build_stream_response(stream, calls_acc)
224
- thinking = stream.chunks.filter_map(&:thinking).last
168
+ tokens = accumulated_tokens(stream)
169
+ cost = calculate_cost(tokens[:input], tokens[:output])
225
170
  ResponseMessage.new(
226
171
  content: stream.accumulated_text,
227
172
  tool_calls: build_current_tool_calls(calls_acc),
228
- thinking: thinking
173
+ thinking: stream.chunks.filter_map(&:thinking).last,
174
+ input_tokens: tokens[:input],
175
+ output_tokens: tokens[:output],
176
+ cost: cost
229
177
  )
230
178
  end
231
179
 
232
- # Build response from non-streaming result.
233
180
  def build_response(msg)
234
181
  tool_calls = msg.tool_calls ? build_tool_call_hash(msg.tool_calls) : {}
235
182
  thinking = msg.respond_to?(:thinking) ? msg.thinking : nil
236
- ResponseMessage.new(content: msg.content.to_s, tool_calls: tool_calls, thinking: thinking)
183
+ metadata = msg.metadata || {}
184
+ input_tokens = metadata[:input_tokens] || metadata["input_tokens"]
185
+ output_tokens = metadata[:output_tokens] || metadata["output_tokens"]
186
+ cost = calculate_cost(input_tokens, output_tokens)
187
+ ResponseMessage.new(
188
+ content: msg.content.to_s,
189
+ tool_calls: tool_calls,
190
+ thinking: thinking,
191
+ input_tokens: input_tokens,
192
+ output_tokens: output_tokens,
193
+ cost: cost
194
+ )
195
+ end
196
+
197
+ def accumulated_tokens(stream)
198
+ input = 0
199
+ output = 0
200
+ stream.chunks.each do |chunk|
201
+ if chunk.usage
202
+ input = chunk.usage[:input_tokens] || chunk.usage["input_tokens"] || input
203
+ output = chunk.usage[:output_tokens] || chunk.usage["output_tokens"] || output
204
+ end
205
+ output += 1 if chunk.content.to_s.length > 0
206
+ end
207
+ { input: input, output: output }
208
+ end
209
+
210
+ def calculate_cost(input_tokens, output_tokens)
211
+ return nil unless input_tokens || output_tokens
212
+ Ask::LLM::CostCalculator.calculate(@model_info, input_tokens: input_tokens || 0, output_tokens: output_tokens || 0)
213
+ rescue StandardError
214
+ nil
215
+ end
216
+
217
+ MAX_CHAT_RETRIES = 3
218
+
219
+ def chat_with_retry(stream, calls_acc, &block)
220
+ MAX_CHAT_RETRIES.times do |attempt|
221
+ begin
222
+ return provider.chat(
223
+ @messages.map(&:to_h),
224
+ model: @model_id,
225
+ tools: @tools.map { |t| Ask::ToolDef.from_tool(t) },
226
+ temperature: @temperature,
227
+ stream: stream,
228
+ schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
229
+ **(@extra_params || {})
230
+ ) do |raw_chunk|
231
+ next unless block
232
+
233
+ accumulate_tool_calls(raw_chunk, calls_acc)
234
+
235
+ block.call(ChatChunk.new(
236
+ content: raw_chunk.content,
237
+ tool_calls: build_current_tool_calls(calls_acc),
238
+ thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil,
239
+ input_tokens: nil,
240
+ output_tokens: nil
241
+ ))
242
+ end
243
+ rescue Ask::RateLimitError => e
244
+ raise if attempt >= MAX_CHAT_RETRIES - 1
245
+
246
+ delay = e.retry_after || ((2 ** attempt) + rand(0.0..1.0))
247
+ sleep(delay)
248
+ end
249
+ end
250
+ end
251
+
252
+ def emit_instrumentation(stream, response_msg)
253
+ return unless defined?(Ask::Instrumentation)
254
+
255
+ payload = {
256
+ model: @model_id,
257
+ provider: @model_info.provider,
258
+ input_tokens: response_msg.input_tokens,
259
+ output_tokens: response_msg.output_tokens,
260
+ cost: response_msg.cost,
261
+ tool_calls: response_msg.tool_call?,
262
+ stream: stream
263
+ }.compact
264
+
265
+ if stream
266
+ Ask::Instrumentation.instrument("chat.stream.ask", payload)
267
+ else
268
+ Ask::Instrumentation.instrument("chat.ask", payload)
269
+ end
270
+ rescue StandardError
271
+ nil
237
272
  end
238
273
  end
239
274
  end
@@ -4,10 +4,10 @@ module Ask
4
4
  module Agent
5
5
  module Events
6
6
  SessionStart = Data.define
7
- SessionEnd = Data.define(:result, :turn_count, :tool_calls_made)
7
+ SessionEnd = Data.define(:result, :turn_count, :tool_calls_made, :input_tokens, :output_tokens, :cost)
8
8
 
9
9
  TurnStart = Data.define
10
- TurnEnd = Data.define(:tool_results, :turn_number)
10
+ TurnEnd = Data.define(:tool_results, :turn_number, :input_tokens, :output_tokens, :cost)
11
11
 
12
12
  MessageStart = Data.define
13
13
  TextDelta = Data.define(:content)
@@ -6,7 +6,7 @@ module Ask
6
6
  LOOP_DETECTION_WINDOW = 3
7
7
  @max_consecutive_tool_turns = 6
8
8
 
9
- attr_reader :turn_count
9
+ attr_reader :turn_count, :last_input_tokens, :last_output_tokens, :last_cost
10
10
 
11
11
  def initialize(max_turns: 25, max_consecutive_tool_turns: 6)
12
12
  @max_turns = max_turns
@@ -36,6 +36,10 @@ module Ask
36
36
  end
37
37
  end
38
38
 
39
+ @last_input_tokens = response.input_tokens
40
+ @last_output_tokens = response.output_tokens
41
+ @last_cost = response.cost
42
+
39
43
  event_emitter.emit(Events::MessageEnd.new(tool_calls: response.tool_call?))
40
44
  @turn_count += 1
41
45
 
@@ -65,7 +69,13 @@ module Ask
65
69
  return "Based on my investigation: #{summary}"
66
70
  end
67
71
 
68
- event_emitter.emit(Events::TurnEnd.new(tool_results: tool_results, turn_number: @turn_count))
72
+ event_emitter.emit(Events::TurnEnd.new(
73
+ tool_results: tool_results,
74
+ turn_number: @turn_count,
75
+ input_tokens: @last_input_tokens,
76
+ output_tokens: @last_output_tokens,
77
+ cost: @last_cost
78
+ ))
69
79
 
70
80
  if compactor && compactor.should_compact?
71
81
  compactor.run(event_emitter: event_emitter)
@@ -7,7 +7,7 @@ module Ask
7
7
  module Agent
8
8
  class Session
9
9
  attr_reader :id, :chat, :tools, :turn_count, :created_at, :messages
10
- attr_reader :tool_calls_made
10
+ attr_reader :tool_calls_made, :total_input_tokens, :total_output_tokens, :total_cost
11
11
 
12
12
  def reflection_count
13
13
  @reflector&.reflection_count || 0
@@ -33,6 +33,10 @@ module Ask
33
33
  @created_at = Time.now
34
34
  @_no_tools_instructed = false
35
35
 
36
+ @total_input_tokens = 0
37
+ @total_output_tokens = 0
38
+ @total_cost = 0.0
39
+
36
40
  @telemetry = telemetry.is_a?(Telemetry) ? telemetry : Telemetry.new(enabled: !!telemetry)
37
41
 
38
42
  @chat = build_chat(model, system_prompt, tools, **chat_options)
@@ -99,6 +103,10 @@ module Ask
99
103
  event_emitter: self,
100
104
  session_id: @id
101
105
  )
106
+
107
+ @total_input_tokens += @loop.last_input_tokens.to_i
108
+ @total_output_tokens += @loop.last_output_tokens.to_i
109
+ @total_cost += @loop.last_cost.to_f
102
110
  rescue MaxTurnsExceeded => e
103
111
  emit(Events::MaxTurnsExceeded.new(max_turns: @max_turns))
104
112
  @telemetry.log(:max_turns_exceeded, session_id: @id, max_turns: @max_turns)
@@ -143,6 +151,10 @@ module Ask
143
151
  event_emitter: self,
144
152
  session_id: @id
145
153
  )
154
+
155
+ @total_input_tokens += @loop.last_input_tokens.to_i
156
+ @total_output_tokens += @loop.last_output_tokens.to_i
157
+ @total_cost += @loop.last_cost.to_f
146
158
  end
147
159
  end
148
160
 
@@ -151,7 +163,14 @@ module Ask
151
163
  try_auto_meta_agent
152
164
  end
153
165
 
154
- emit(Events::SessionEnd.new(result: response, turn_count: @turn_count, tool_calls_made: @tool_calls_made))
166
+ emit(Events::SessionEnd.new(
167
+ result: response,
168
+ turn_count: @turn_count,
169
+ tool_calls_made: @tool_calls_made,
170
+ input_tokens: @total_input_tokens,
171
+ output_tokens: @total_output_tokens,
172
+ cost: @total_cost
173
+ ))
155
174
  @messages = @chat.messages.dup
156
175
 
157
176
  response
@@ -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.2.2"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
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.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: ask-instrumentation
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: minitest
70
84
  requirement: !ruby/object:Gem::Requirement