ask-agent 0.2.2 → 0.3.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: 38ad7fd8e88fbb94137ceff0dacd127b598e3487c69b1b58feaab1299c3e0d35
4
- data.tar.gz: 6b86830a8ef5547305efdb5d8aa36b38de9d4ea0b1ce55c6c207668575ff554c
3
+ metadata.gz: 4812b86203eb5a12eb03138de1e50fe8f0a80532f545bb683dcf051f2729c088
4
+ data.tar.gz: ba9e999b2aedae3e9c0f55067a35e3d3f29f828717a8f7129c0e0037bd518a33
5
5
  SHA512:
6
- metadata.gz: c9e4653c5d70595143daf009735193f984e7f972bc075658508b8baf26d3fcdb044fa251c50d4de95593b268ec2715334c67bc2e7a7578fee419f2da4eab8cd0
7
- data.tar.gz: e375fe521571910a21c00dfe169b506fc617e0c46bc235e108369c197ee866c05e42813905dcbc9445c898d8143d1b826666dd6593816feefa5fde7b8f539a8c
6
+ metadata.gz: a5b049b05acf1a82334a2169a708d60a6511db2d790ec5db3f30b9b4775a561eb204b8fdb209b60d095edeba43bd27f1572a909d78118eba9f295943625da370
7
+ data.tar.gz: 54ba99bbacd6152c52196f276f28559d2a13e09b048338c4c5198e9ab01c8f2cc9e5eb57c2a957b1ba6dea4410726c3eba644104b66d767729e908a10c366133
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.3.0] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **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.
6
+ - **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).
7
+ - **Cost in agent events** — `SessionEnd` and `TurnEnd` events now include `input_tokens`, `output_tokens`, and `cost` fields, accumulated across all turns in the session.
8
+ - **Cumulative session costs** — `Session` tracks `total_input_tokens`, `total_output_tokens`, and `total_cost` across all turns and reflection rounds.
9
+
10
+ ### Changed
11
+
12
+ - **Dependency added** — `ask-instrumentation >= 0.1` added to gemspec. Instrumentation is optional (emission is wrapped in `defined?` check).
13
+ - **Gemfile** — now uses local path resolution for sibling ask-* gems during development.
14
+
1
15
  ## [0.2.1] - 2026-06-25
2
16
 
3
17
  ### 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,38 +35,39 @@ 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,
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 = provider.chat(
53
+ @messages.map(&:to_h),
54
+ model: provider_model,
55
+ tools: provider_tools,
56
+ temperature: provider_temp,
71
57
  stream: stream,
72
- schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
73
- **(@extra_params || {})
58
+ schema: provider_schema,
59
+ **provider_params
74
60
  ) do |raw_chunk|
75
61
  next unless block_given?
76
62
 
77
- # Accumulate tool calls by index during streaming
78
63
  accumulate_tool_calls(raw_chunk, calls_acc)
79
64
 
80
- # Yield adapted chunk with current tool call state
81
65
  yield ChatChunk.new(
82
66
  content: raw_chunk.content,
83
67
  tool_calls: build_current_tool_calls(calls_acc),
84
- thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil
68
+ thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil,
69
+ input_tokens: nil,
70
+ output_tokens: nil
85
71
  )
86
72
  end
87
73
 
@@ -91,24 +77,24 @@ module Ask
91
77
  build_response(result)
92
78
  end
93
79
 
94
- # Store assistant response in conversation history
95
80
  @messages << Ask::Message.new(
96
81
  role: :assistant,
97
82
  content: response_msg.content,
98
83
  tool_calls: response_msg.tool_calls&.values&.map { |tc|
99
84
  { id: tc.id, type: "function", name: tc.name, arguments: tc.arguments }
100
- }
85
+ },
86
+ metadata: {
87
+ input_tokens: response_msg.input_tokens,
88
+ output_tokens: response_msg.output_tokens,
89
+ cost: response_msg.cost
90
+ }.compact
101
91
  )
102
92
 
93
+ emit_instrumentation(stream, response_msg)
94
+
103
95
  response_msg
104
96
  end
105
97
 
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
98
  def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil)
113
99
  @messages << Ask::Message.new(
114
100
  role: role,
@@ -118,46 +104,28 @@ module Ask
118
104
  )
119
105
  end
120
106
 
121
- # Set or replace the system prompt.
122
- #
123
- # @param prompt [String] system instructions
124
- # @return [self]
125
107
  def with_instructions(prompt)
126
108
  @messages.reject! { |m| m.role == :system }
127
109
  @messages.unshift(Ask::Message.new(role: :system, content: prompt))
128
110
  self
129
111
  end
130
112
 
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
113
+ def with_params(**params)
114
+ @extra_params = (@extra_params || {}).merge(params)
115
+ self
116
+ end
143
117
 
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)
118
+ def with_schema(schema)
148
119
  @schema = schema.respond_to?(:to_json_schema) ? schema.to_json_schema : schema
149
120
  self
150
121
  end
151
122
 
152
- # Clear all messages from the conversation.
153
123
  def reset_messages!
154
124
  @messages.clear
155
125
  end
156
126
 
157
127
  private
158
128
 
159
- # Resolve model info from the catalog.
160
- # Lazily resolve and instantiate the LLM provider.
161
129
  def provider
162
130
  @provider ||= build_provider
163
131
  end
@@ -177,7 +145,6 @@ def with_schema(schema)
177
145
  Ask::LLM::Config.new(config)
178
146
  end
179
147
 
180
- # Accumulate partial tool calls from streaming chunks.
181
148
  def accumulate_tool_calls(raw_chunk, calls_acc)
182
149
  return unless raw_chunk.tool_call?
183
150
 
@@ -190,7 +157,6 @@ def with_schema(schema)
190
157
  end
191
158
  end
192
159
 
193
- # Build current snapshot of tool calls from accumulator.
194
160
  def build_current_tool_calls(calls_acc)
195
161
  hash = {}
196
162
  calls_acc.each_value do |tc_data|
@@ -204,7 +170,6 @@ def with_schema(schema)
204
170
  hash
205
171
  end
206
172
 
207
- # Convert Ask::Provider tool_calls (Array of Hashes) to Hash.
208
173
  def build_tool_call_hash(raw_calls)
209
174
  hash = {}
210
175
  raw_calls.each do |tc|
@@ -219,21 +184,76 @@ def with_schema(schema)
219
184
  hash
220
185
  end
221
186
 
222
- # Build response from streaming result.
223
187
  def build_stream_response(stream, calls_acc)
224
- thinking = stream.chunks.filter_map(&:thinking).last
188
+ tokens = accumulated_tokens(stream)
189
+ cost = calculate_cost(tokens[:input], tokens[:output])
225
190
  ResponseMessage.new(
226
191
  content: stream.accumulated_text,
227
192
  tool_calls: build_current_tool_calls(calls_acc),
228
- thinking: thinking
193
+ thinking: stream.chunks.filter_map(&:thinking).last,
194
+ input_tokens: tokens[:input],
195
+ output_tokens: tokens[:output],
196
+ cost: cost
229
197
  )
230
198
  end
231
199
 
232
- # Build response from non-streaming result.
233
200
  def build_response(msg)
234
201
  tool_calls = msg.tool_calls ? build_tool_call_hash(msg.tool_calls) : {}
235
202
  thinking = msg.respond_to?(:thinking) ? msg.thinking : nil
236
- ResponseMessage.new(content: msg.content.to_s, tool_calls: tool_calls, thinking: thinking)
203
+ metadata = msg.metadata || {}
204
+ input_tokens = metadata[:input_tokens] || metadata["input_tokens"]
205
+ output_tokens = metadata[:output_tokens] || metadata["output_tokens"]
206
+ cost = calculate_cost(input_tokens, output_tokens)
207
+ ResponseMessage.new(
208
+ content: msg.content.to_s,
209
+ tool_calls: tool_calls,
210
+ thinking: thinking,
211
+ input_tokens: input_tokens,
212
+ output_tokens: output_tokens,
213
+ cost: cost
214
+ )
215
+ end
216
+
217
+ def accumulated_tokens(stream)
218
+ input = 0
219
+ output = 0
220
+ stream.chunks.each do |chunk|
221
+ if chunk.usage
222
+ input = chunk.usage[:input_tokens] || chunk.usage["input_tokens"] || input
223
+ output = chunk.usage[:output_tokens] || chunk.usage["output_tokens"] || output
224
+ end
225
+ output += 1 if chunk.content.to_s.length > 0
226
+ end
227
+ { input: input, output: output }
228
+ end
229
+
230
+ def calculate_cost(input_tokens, output_tokens)
231
+ return nil unless input_tokens || output_tokens
232
+ Ask::LLM::CostCalculator.calculate(@model_info, input_tokens: input_tokens || 0, output_tokens: output_tokens || 0)
233
+ rescue StandardError
234
+ nil
235
+ end
236
+
237
+ def emit_instrumentation(stream, response_msg)
238
+ return unless defined?(Ask::Instrumentation)
239
+
240
+ payload = {
241
+ model: @model_id,
242
+ provider: @model_info.provider,
243
+ input_tokens: response_msg.input_tokens,
244
+ output_tokens: response_msg.output_tokens,
245
+ cost: response_msg.cost,
246
+ tool_calls: response_msg.tool_call?,
247
+ stream: stream
248
+ }.compact
249
+
250
+ if stream
251
+ Ask::Instrumentation.instrument("chat.stream.ask", payload)
252
+ else
253
+ Ask::Instrumentation.instrument("chat.ask", payload)
254
+ end
255
+ rescue StandardError
256
+ nil
237
257
  end
238
258
  end
239
259
  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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
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.0
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