ask-agent 0.27.0 → 0.28.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: f67174ee80499aad6a21ff621f02c341daf0e992392996d577218aec2d8987ab
4
- data.tar.gz: 25b21cc738b1e106730330b3ec28b118545f96a296898e4e9b3e337b4a2bf9a0
3
+ metadata.gz: 36e26d74ffa1c3e3e97b8e172f4fef2ada07c1c194488a8f550e8b77b7473ee1
4
+ data.tar.gz: 82568a4cc3ed19617293416d10f2f3194c1a18079a1426431a2d878afee75fa0
5
5
  SHA512:
6
- metadata.gz: 74fdca8fecf20a3707bf162a319a0b4fa910f3718745fa8276235200e66e98ddd9a005cd7d4ce4bb5fc7d74321a1a86983934cc17041e6a20e58bce48156ce75
7
- data.tar.gz: 78250a88b07fe2265e276ab5f156dd1c712315585eb906d81a198825c63450cf470ec48702bfbab2ac4b4d757523ff53a86d28646293e21809891626af4616c7
6
+ metadata.gz: cfd202f83bb95e3349282c3771e591f93062976b58c964d65e928e09a6408818ef969200c10b68c4a307e0c0d9fabde112aaf20b9776110a5040590370ecd317
7
+ data.tar.gz: 7d3ae82b872659e9e27217bc98cf7dfffee28f5209ee5f351dcc944564a63a777888fdad780de218956019d38f223996ebd6372c7be84cf33fdbf9a1eeb4e5a7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## [0.28.0] — 2026-08-06
2
+
3
+ ### Changed (breaking)
4
+
5
+ - **`Ask::Agent::Extensions` is now `Ask::Agent::Policies`.** The
6
+ tool-lifecycle policy classes — `ApprovalPolicy`, `Permissions`,
7
+ `RateLimiter`, `AuditLog` — moved from `lib/ask/agent/extensions/` to
8
+ `lib/ask/agent/policies/` and are namespaced under `Ask::Agent::Policies`.
9
+ The folder is now named after its seam (like `middleware`,
10
+ `stream_transforms`, `persistence`) instead of "extra stuff".
11
+ `Ask::Agent.load_extensions` → `Ask::Agent.load_policies`.
12
+ Update references: `Ask::Agent::Extensions::X` → `Ask::Agent::Policies::X`.
13
+
14
+ **What this means for the taxonomy:** policies are opt-in, replaceable
15
+ implementations of the tool-lifecycle hook seam — the agent loop runs
16
+ without them, and users can swap in their own implementations. Core
17
+ mechanisms are unchanged and stay on `Session`: the approval queue, the
18
+ `:pending` result status, and the `approval: true` option are core;
19
+ `Policies::ApprovalPolicy` is the reference classification policy wired on
20
+ top of them.
21
+
22
+ ### Added
23
+
24
+ - **`Ask::Agent.load_policies`** — replaces `load_extensions` (same
25
+ behavior: eagerly requires every policy in the policies directory).
26
+
27
+ ## [0.27.1] — 2026-08-06
28
+
29
+ ### Fixed
30
+
31
+ - **`chat.ask` / `chat.stream.ask` events now measure real LLM latency.** The
32
+ event was emitted after the call without a block, so `event.duration` was
33
+ ~0ms and duration metrics (e.g. `ask_llm_duration_seconds`,
34
+ `llm.duration_ms` spans) were meaningless. The provider call now runs
35
+ inside the instrument block; tokens/cost/tool_calls are enriched through a
36
+ shared nested `usage` payload hash (known only after the call returns) and
37
+ subscribers read it from there. Instrumentation failures can no longer
38
+ fail an `ask` — a wrapper error before the call falls through and runs the
39
+ call without telemetry, and a subscriber error after success returns the
40
+ response.
41
+
1
42
  ## [0.27.0] — 2026-08-06
2
43
 
3
44
  ### Added
@@ -73,8 +73,6 @@ module Ask
73
73
  }.compact
74
74
  )
75
75
 
76
- emit_instrumentation(stream, response_msg)
77
-
78
76
  response_msg
79
77
  end
80
78
 
@@ -208,20 +206,35 @@ module Ask
208
206
  begin
209
207
  req = build_request(stream)
210
208
 
211
- result = if @middleware_pipeline
212
- @middleware_pipeline.invoke(provider, req) do
209
+ # The chat.ask event wraps the actual provider call so
210
+ # event.duration measures the true LLM latency. Tokens/cost are
211
+ # only known once the call returns, so the (mutable) payload is
212
+ # passed into the block and enriched there — before the event's
213
+ # finish fires.
214
+ response = instrument_llm_call(stream) do |payload|
215
+ result = if @middleware_pipeline
216
+ @middleware_pipeline.invoke(provider, req) do
217
+ call_provider(req, calls_acc, &block)
218
+ end
219
+ else
213
220
  call_provider(req, calls_acc, &block)
214
221
  end
215
- else
216
- call_provider(req, calls_acc, &block)
217
- end
218
222
 
219
- # Flush any buffered stream transforms (e.g. TextBuffer)
220
- if block && @transform_pipeline
221
- flush_transforms(&block)
223
+ # Flush any buffered stream transforms (e.g. TextBuffer)
224
+ if block && @transform_pipeline
225
+ flush_transforms(&block)
226
+ end
227
+
228
+ response = build_response_from_result(result, calls_acc, stream)
229
+ usage = payload[:usage] ||= {}
230
+ usage[:input_tokens] = response.input_tokens
231
+ usage[:output_tokens] = response.output_tokens
232
+ usage[:cost] = response.cost
233
+ usage[:tool_calls] = response.tool_call?
234
+ response
222
235
  end
223
236
 
224
- return build_response_from_result(result, calls_acc, stream)
237
+ return response
225
238
  rescue Ask::RateLimitError => e
226
239
  raise if attempt >= MAX_CHAT_RETRIES - 1
227
240
 
@@ -357,28 +370,47 @@ module Ask
357
370
  nil
358
371
  end
359
372
 
360
- def emit_instrumentation(stream, response_msg)
361
- return unless defined?(Ask::Instrumentation)
362
-
373
+ # Run the provider call inside the chat.ask event so event.duration
374
+ # measures the real LLM latency. The event payload is yielded to the
375
+ # block so the caller can enrich it (tokens, cost) before the event
376
+ # finishes.
377
+ #
378
+ # Instrumentation must never break the chat loop: a subscriber error
379
+ # after the call succeeded is swallowed (the response is returned);
380
+ # an error before the block ran falls through and runs the call
381
+ # without telemetry. Only real LLM errors propagate.
382
+ def instrument_llm_call(stream)
363
383
  payload = {
364
384
  model: @model_id,
365
385
  provider: @model_info.provider,
366
- input_tokens: response_msg.input_tokens,
367
- output_tokens: response_msg.output_tokens,
368
- cost: response_msg.cost,
369
- tool_calls: response_msg.tool_call?,
370
386
  stream: stream,
371
387
  middleware: @middleware_pipeline&.configured?,
372
- stream_transforms: @transform_pipeline&.configured?
388
+ stream_transforms: @transform_pipeline&.configured?,
389
+ # Tokens/cost are only known once the call returns, and
390
+ # instrument() copies the payload shallowly — this nested hash is
391
+ # shared with the event, so in-block enrichment is visible to
392
+ # subscribers at finish time.
393
+ usage: {}
373
394
  }.compact
374
395
 
375
- if stream
376
- Ask::Instrumentation.instrument("chat.stream.ask", payload)
396
+ called = false
397
+ result = nil
398
+ if defined?(Ask::Instrumentation)
399
+ Ask::Instrumentation.instrument(stream ? "chat.stream.ask" : "chat.ask", payload) do
400
+ called = true
401
+ result = yield(payload)
402
+ end
377
403
  else
378
- Ask::Instrumentation.instrument("chat.ask", payload)
404
+ called = true
405
+ result = yield(payload)
379
406
  end
407
+ result
380
408
  rescue StandardError
381
- nil
409
+ return result if called && !result.nil?
410
+
411
+ raise if called
412
+
413
+ yield({})
382
414
  end
383
415
  end
384
416
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- module Extensions
5
+ module Policies
6
6
  # Approval policy hook: classifies tool calls as approval-required and
7
7
  # routes them into an {Ask::Agent::ApprovalQueue}.
8
8
  #
@@ -15,7 +15,7 @@ module Ask
15
15
  #
16
16
  # @example
17
17
  # queue = Ask::Agent::ApprovalQueue.new
18
- # policy = Ask::Agent::Extensions::ApprovalPolicy.new(queue: queue)
18
+ # policy = Ask::Agent::Policies::ApprovalPolicy.new(queue: queue)
19
19
  # session = Ask::Agent::Session.new(
20
20
  # model: "gpt-4o",
21
21
  # tools: [SendEmail],
@@ -3,7 +3,7 @@ require "time"
3
3
 
4
4
  module Ask
5
5
  module Agent
6
- module Extensions
6
+ module Policies
7
7
  class AuditLog
8
8
  # ActiveRecord adapter for the audit log.
9
9
  # Auto-creates the +ask_audit_logs+ table on first write using
@@ -5,7 +5,7 @@ require "time"
5
5
 
6
6
  module Ask
7
7
  module Agent
8
- module Extensions
8
+ module Policies
9
9
  # Event-driven audit log for agent sessions.
10
10
  #
11
11
  # Subscribes to all session events and writes them to a configurable
@@ -93,16 +93,16 @@ module Ask
93
93
 
94
94
  case adapter
95
95
  when :active_record
96
- require "ask/agent/extensions/audit_log/active_record_writer"
96
+ require "ask/agent/policies/audit_log/active_record_writer"
97
97
  AuditLog::ActiveRecordWriter.new
98
98
  when Hash
99
99
  resolve(adapter[:adapter] || adapter[:writer])
100
100
  when Symbol, String
101
101
  # Try to load adapter by convention:
102
- # :active_record → ask/agent/extensions/audit_log/active_record_writer
102
+ # :active_record → ask/agent/policies/audit_log/active_record_writer
103
103
  name = adapter.to_s
104
104
  begin
105
- require "ask/agent/extensions/audit_log/#{name}_writer"
105
+ require "ask/agent/policies/audit_log/#{name}_writer"
106
106
  klass_name = name.split("_").map(&:capitalize).join
107
107
  klass = AuditLog.const_get(klass_name)
108
108
  klass.new
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- module Extensions
5
+ module Policies
6
6
  class Permissions
7
7
  DEFAULT_TOOLS = %i[write edit bash destroy].freeze
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- module Extensions
5
+ module Policies
6
6
  class RateLimiter
7
7
  def initialize(max_calls_per_minute: 20, max_tool_calls_per_turn: 5)
8
8
  @max_calls_per_minute = max_calls_per_minute
@@ -421,7 +421,7 @@ module Ask
421
421
  def build_audit_log(config)
422
422
  config ||= Ask::Agent.configuration.audit_log
423
423
  return nil unless config
424
- Ask::Agent::Extensions::AuditLog.new(self, adapter: config)
424
+ Ask::Agent::Policies::AuditLog.new(self, adapter: config)
425
425
  end
426
426
 
427
427
  # Build the approval queue + policy when approval is enabled.
@@ -451,7 +451,7 @@ module Ask
451
451
  )
452
452
  end
453
453
 
454
- policy = Ask::Agent::Extensions::ApprovalPolicy.new(
454
+ policy = Ask::Agent::Policies::ApprovalPolicy.new(
455
455
  queue: queue,
456
456
  require_approval: policy_opts[:require_approval],
457
457
  tools: @tools
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.27.0"
5
+ VERSION = "0.28.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -21,11 +21,20 @@ module Ask
21
21
 
22
22
  class UnknownAgent < Error; end
23
23
 
24
- module Extensions
25
- autoload :Permissions, "ask/agent/extensions/permissions"
26
- autoload :RateLimiter, "ask/agent/extensions/rate_limiter"
27
- autoload :AuditLog, "ask/agent/extensions/audit_log"
28
- autoload :ApprovalPolicy, "ask/agent/extensions/approval_policy"
24
+ # Policies are opt-in, replaceable implementations of the tool-lifecycle
25
+ # hook seam (before_tool / after_tool). The agent loop runs without them
26
+ # and their semantics are unchanged by their absence; users compose and
27
+ # swap them freely (see Ask::Agent::Hooks for the seam).
28
+ #
29
+ # A policy is NOT core machinery. Core mechanisms live on Session — the
30
+ # approval queue, the :pending result status, and the `approval: true`
31
+ # option are core; Policies::ApprovalPolicy is the reference
32
+ # classification policy wired on top of them.
33
+ module Policies
34
+ autoload :Permissions, "ask/agent/policies/permissions"
35
+ autoload :RateLimiter, "ask/agent/policies/rate_limiter"
36
+ autoload :AuditLog, "ask/agent/policies/audit_log"
37
+ autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
29
38
  end
30
39
 
31
40
  module Middleware
@@ -236,8 +245,8 @@ module Ask
236
245
  yield configuration
237
246
  end
238
247
 
239
- def self.load_extensions
240
- Dir[File.expand_path("agent/extensions/*.rb", __dir__)].each { |f| require f }
248
+ def self.load_policies
249
+ Dir[File.expand_path("agent/policies/*.rb", __dir__)].each { |f| require f }
241
250
  rescue Errno::ENOENT
242
251
  end
243
252
  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.27.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -174,11 +174,6 @@ files:
174
174
  - lib/ask/agent/definition.rb
175
175
  - lib/ask/agent/evaluator.rb
176
176
  - lib/ask/agent/events.rb
177
- - lib/ask/agent/extensions/approval_policy.rb
178
- - lib/ask/agent/extensions/audit_log.rb
179
- - lib/ask/agent/extensions/audit_log/active_record_writer.rb
180
- - lib/ask/agent/extensions/permissions.rb
181
- - lib/ask/agent/extensions/rate_limiter.rb
182
177
  - lib/ask/agent/hooks.rb
183
178
  - lib/ask/agent/loop.rb
184
179
  - lib/ask/agent/meta_agent.rb
@@ -190,6 +185,11 @@ files:
190
185
  - lib/ask/agent/middleware/retry_on_failure.rb
191
186
  - lib/ask/agent/persistence/base.rb
192
187
  - lib/ask/agent/persistence/in_memory.rb
188
+ - lib/ask/agent/policies/approval_policy.rb
189
+ - lib/ask/agent/policies/audit_log.rb
190
+ - lib/ask/agent/policies/audit_log/active_record_writer.rb
191
+ - lib/ask/agent/policies/permissions.rb
192
+ - lib/ask/agent/policies/rate_limiter.rb
193
193
  - lib/ask/agent/reflector.rb
194
194
  - lib/ask/agent/scheduler.rb
195
195
  - lib/ask/agent/session.rb