ask-agent 0.2.1 → 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: 6931fde5be098a92af7a2dc3ae106c97aa925535992a2ef354dfe76bbc79c819
4
- data.tar.gz: 3772209146d3c64b1b8a1e1086cbdbfbf6464b6da844362aa4e63e8b16815b8b
3
+ metadata.gz: 4812b86203eb5a12eb03138de1e50fe8f0a80532f545bb683dcf051f2729c088
4
+ data.tar.gz: ba9e999b2aedae3e9c0f55067a35e3d3f29f828717a8f7129c0e0037bd518a33
5
5
  SHA512:
6
- metadata.gz: 698c25a1cea3af3c46801b5da5bed336f1982b480200b133a730f259a90d7b0e4b5e038a703ff7a5672fee7ea57966bf405d1e2833046a31eea51d2334c73f2a
7
- data.tar.gz: 2cce1e6b2c7afde036aa5d53f649c02722bf62e14c896d9fc08b07de7de6b2f3a59161dfd2e5bfa391a211c3794b582ee4243ae0f4eb9addf261f611ba1871c8
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,47 +3,30 @@
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 assume_model_exists [Boolean] whether to skip model catalog lookup
42
- # @param provider [String, Symbol, nil] provider slug to use (overrides model catalog)
43
- def initialize(model:, tools: [], temperature: nil, schema: nil,
44
- assume_model_exists: false, provider: nil, **)
27
+ def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, **)
45
28
  @model_id = model.respond_to?(:id) ? model.id : model.to_s
46
- @model_info = resolve_model(@model_id) unless assume_model_exists
29
+ @model_info = Ask::ModelCatalog.find(@model_id)
47
30
  @tools = tools
48
31
  @temperature = temperature
49
32
  @schema = schema
@@ -52,38 +35,39 @@ module Ask
52
35
  @provider = nil
53
36
  end
54
37
 
55
- # Send a user message and get a completion response.
56
- #
57
- # @param message [String, nil] user message text
58
- # @yield [ChatChunk] streaming chunks (only when a block is given)
59
- # @return [ResponseMessage] the assistant's response
60
38
  def ask(message = nil, &block)
61
39
  @messages << Ask::Message.new(role: :user, content: message.to_s) if message
62
40
 
63
41
  stream = block_given?
64
42
  tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
65
43
 
66
- # Accumulator for tool calls during streaming (keyed by index)
67
44
  calls_acc = {}
68
45
 
69
- result = provider.chat( @messages.map(&:to_h),
70
- model: @model_id,
71
- tools: tool_defs,
72
- 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,
73
57
  stream: stream,
74
- schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema,
75
- **(@extra_params || {})
58
+ schema: provider_schema,
59
+ **provider_params
76
60
  ) do |raw_chunk|
77
61
  next unless block_given?
78
62
 
79
- # Accumulate tool calls by index during streaming
80
63
  accumulate_tool_calls(raw_chunk, calls_acc)
81
64
 
82
- # Yield adapted chunk with current tool call state
83
65
  yield ChatChunk.new(
84
66
  content: raw_chunk.content,
85
67
  tool_calls: build_current_tool_calls(calls_acc),
86
- 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
87
71
  )
88
72
  end
89
73
 
@@ -93,24 +77,24 @@ module Ask
93
77
  build_response(result)
94
78
  end
95
79
 
96
- # Store assistant response in conversation history
97
80
  @messages << Ask::Message.new(
98
81
  role: :assistant,
99
82
  content: response_msg.content,
100
83
  tool_calls: response_msg.tool_calls&.values&.map { |tc|
101
84
  { id: tc.id, type: "function", name: tc.name, arguments: tc.arguments }
102
- }
85
+ },
86
+ metadata: {
87
+ input_tokens: response_msg.input_tokens,
88
+ output_tokens: response_msg.output_tokens,
89
+ cost: response_msg.cost
90
+ }.compact
103
91
  )
104
92
 
93
+ emit_instrumentation(stream, response_msg)
94
+
105
95
  response_msg
106
96
  end
107
97
 
108
- # Add a message to the conversation history.
109
- #
110
- # @param role [Symbol] :system, :user, :assistant, :tool
111
- # @param content [String, nil] message content
112
- # @param tool_call_id [String, nil] tool call ID (for tool results)
113
- # @param tool_calls [Array<Hash>, nil] tool call invocations
114
98
  def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil)
115
99
  @messages << Ask::Message.new(
116
100
  role: role,
@@ -120,58 +104,34 @@ module Ask
120
104
  )
121
105
  end
122
106
 
123
- # Set or replace the system prompt.
124
- #
125
- # @param prompt [String] system instructions
126
- # @return [self]
127
107
  def with_instructions(prompt)
128
108
  @messages.reject! { |m| m.role == :system }
129
109
  @messages.unshift(Ask::Message.new(role: :system, content: prompt))
130
110
  self
131
111
  end
132
112
 
133
- # Set the structured output schema and return self.
134
- #
135
- # @param schema [Ask::Schema, Hash] structured output schema
136
- # @return [self]
137
- # Set additional parameters for the provider call and return self.
138
- #
139
- # @param params [Hash] extra parameters passed to the provider
140
- # @return [self]
141
- def with_params(**params)
142
- @extra_params = (@extra_params || {}).merge(params)
143
- self
144
- end
113
+ def with_params(**params)
114
+ @extra_params = (@extra_params || {}).merge(params)
115
+ self
116
+ end
145
117
 
146
- # Set additional parameters forwarded to the provider call.
147
- # @param params [Hash] extra keyword arguments for the provider
148
- # @return [self]
149
- def with_schema(schema)
118
+ def with_schema(schema)
150
119
  @schema = schema.respond_to?(:to_json_schema) ? schema.to_json_schema : schema
151
120
  self
152
121
  end
153
122
 
154
- # Clear all messages from the conversation.
155
123
  def reset_messages!
156
124
  @messages.clear
157
125
  end
158
126
 
159
127
  private
160
128
 
161
- # Resolve model info from the catalog.
162
- def resolve_model(model_id)
163
- Ask::ModelCatalog.find(model_id)
164
- rescue Ask::ModelNotFound
165
- nil
166
- end
167
-
168
- # Lazily resolve and instantiate the LLM provider.
169
129
  def provider
170
130
  @provider ||= build_provider
171
131
  end
172
132
 
173
133
  def build_provider
174
- slug = @provider_override&.to_s || @model_info&.provider || "openai"
134
+ slug = @provider_override&.to_s || @model_info.provider
175
135
  klass = Ask::Provider.resolve(slug)
176
136
  klass.new(provider_config(slug))
177
137
  end
@@ -185,7 +145,6 @@ def with_schema(schema)
185
145
  Ask::LLM::Config.new(config)
186
146
  end
187
147
 
188
- # Accumulate partial tool calls from streaming chunks.
189
148
  def accumulate_tool_calls(raw_chunk, calls_acc)
190
149
  return unless raw_chunk.tool_call?
191
150
 
@@ -198,7 +157,6 @@ def with_schema(schema)
198
157
  end
199
158
  end
200
159
 
201
- # Build current snapshot of tool calls from accumulator.
202
160
  def build_current_tool_calls(calls_acc)
203
161
  hash = {}
204
162
  calls_acc.each_value do |tc_data|
@@ -212,7 +170,6 @@ def with_schema(schema)
212
170
  hash
213
171
  end
214
172
 
215
- # Convert Ask::Provider tool_calls (Array of Hashes) to Hash.
216
173
  def build_tool_call_hash(raw_calls)
217
174
  hash = {}
218
175
  raw_calls.each do |tc|
@@ -227,21 +184,76 @@ def with_schema(schema)
227
184
  hash
228
185
  end
229
186
 
230
- # Build response from streaming result.
231
187
  def build_stream_response(stream, calls_acc)
232
- thinking = stream.chunks.filter_map(&:thinking).last
188
+ tokens = accumulated_tokens(stream)
189
+ cost = calculate_cost(tokens[:input], tokens[:output])
233
190
  ResponseMessage.new(
234
191
  content: stream.accumulated_text,
235
192
  tool_calls: build_current_tool_calls(calls_acc),
236
- thinking: thinking
193
+ thinking: stream.chunks.filter_map(&:thinking).last,
194
+ input_tokens: tokens[:input],
195
+ output_tokens: tokens[:output],
196
+ cost: cost
237
197
  )
238
198
  end
239
199
 
240
- # Build response from non-streaming result.
241
200
  def build_response(msg)
242
201
  tool_calls = msg.tool_calls ? build_tool_call_hash(msg.tool_calls) : {}
243
202
  thinking = msg.respond_to?(:thinking) ? msg.thinking : nil
244
- 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
245
257
  end
246
258
  end
247
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.1"
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.1
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