ask-agent 0.27.1 → 0.29.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: f1728d64a66438d1ff5ed51490420b7f9f87d806fc8a5bd98317d9c4de44e68e
4
- data.tar.gz: 2172947edba0c5a906ebdc01643eaa0adda23d1405d22dcb0dd5a662c71e4944
3
+ metadata.gz: 11cf486fa2d955d92cc6f8268f30e5a418b6f0567cdfb6fe6eac015a3352c74b
4
+ data.tar.gz: 5b4990efc0b66de943f6e5c3b7940d31cca6295c54c40fafef485c6004433601
5
5
  SHA512:
6
- metadata.gz: cb6be0c48b35c3b7e68ba73edc98fbe9209ffcdc58ee2a7524dd12ea0d3202b0d62397f449579d4b8154b97876cfc57da1031eb004a02fb25c61765b11e764bd
7
- data.tar.gz: e2d4f5c8fa6808c0d1e73e9d237c56d8e48fb4bc8630b9bf0feb89309bab872a1bca8a42d603030dafadeeb30d203e35e413a533dded5d3a9086e8b8031ef3fc
6
+ metadata.gz: 3adf87ccd9b0cbb606133cb91b8baf9e24a328f96e4ed8f77266258883b13663762cbd16a3e1e34475ec0b93ab763518c4b661fa675fba37985144c01435a736
7
+ data.tar.gz: b6093801427cf4c8bc56fd9b6b0bf6f9fd6bc5252ce336731149413984b5f83862e4cfeae2e9705b2517bb73e3063091950293f74cdb32109624965e52ccc760
data/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
1
+ ## [0.29.0] — 2026-08-06
2
+
3
+ ### Added
4
+
5
+ - **Tool-call repair** — malformed tool calls get one internal LLM round-trip
6
+ to fix them before execution. When a model emits a call with unparseable
7
+ arguments or an unknown tool name, the loop asks the model to re-emit it
8
+ corrected and executes the corrected version instead of burning a turn on
9
+ the error. Enable with `Session.new(tool_call_repair: true)` (built-in
10
+ repair prompt) or pass a callable for full control:
11
+ `Session.new(tool_call_repair: ->(chat, calls, tools) { ... })`.
12
+ - Corrections are remapped to the original call ids, so tool results stay
13
+ consistent with the conversation history; the internal repair exchange
14
+ is stripped from history.
15
+ - Calls the model cannot correct are dropped (the model saw them in the
16
+ repair prompt); repair is best-effort — a failing round-trip drops the
17
+ malformed calls instead of failing the turn.
18
+ - `Events::ToolCallRepaired` fires with name, id, original and corrected
19
+ arguments.
20
+
21
+ ## [0.28.0] — 2026-08-06
22
+
23
+ ### Changed (breaking)
24
+
25
+ - **`Ask::Agent::Extensions` is now `Ask::Agent::Policies`.** The
26
+ tool-lifecycle policy classes — `ApprovalPolicy`, `Permissions`,
27
+ `RateLimiter`, `AuditLog` — moved from `lib/ask/agent/extensions/` to
28
+ `lib/ask/agent/policies/` and are namespaced under `Ask::Agent::Policies`.
29
+ The folder is now named after its seam (like `middleware`,
30
+ `stream_transforms`, `persistence`) instead of "extra stuff".
31
+ `Ask::Agent.load_extensions` → `Ask::Agent.load_policies`.
32
+ Update references: `Ask::Agent::Extensions::X` → `Ask::Agent::Policies::X`.
33
+
34
+ **What this means for the taxonomy:** policies are opt-in, replaceable
35
+ implementations of the tool-lifecycle hook seam — the agent loop runs
36
+ without them, and users can swap in their own implementations. Core
37
+ mechanisms are unchanged and stay on `Session`: the approval queue, the
38
+ `:pending` result status, and the `approval: true` option are core;
39
+ `Policies::ApprovalPolicy` is the reference classification policy wired on
40
+ top of them.
41
+
42
+ ### Added
43
+
44
+ - **`Ask::Agent.load_policies`** — replaces `load_extensions` (same
45
+ behavior: eagerly requires every policy in the policies directory).
46
+
1
47
  ## [0.27.1] — 2026-08-06
2
48
 
3
49
  ### Fixed
@@ -16,6 +16,9 @@ module Ask
16
16
  MessageEnd = Data.define(:tool_calls)
17
17
 
18
18
  ToolExecutionStart = Data.define(:name, :arguments, :id)
19
+ # Emitted when a malformed tool call (unparseable arguments or unknown
20
+ # tool) was repaired by the model before execution.
21
+ ToolCallRepaired = Data.define(:name, :id, :original_arguments, :corrected_arguments)
19
22
  # Emitted when a tool returns Ask::Result.pending (async work started).
20
23
  ToolPending = Data.define(:name, :id)
21
24
  # Emitted when a pending (async) tool's background work completes.
@@ -17,7 +17,7 @@ module Ask
17
17
  @max_consecutive_tool_turns = max_consecutive_tool_turns
18
18
  end
19
19
 
20
- def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil)
20
+ def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil)
21
21
  raise MaxTurnsExceeded if @turn_count >= @max_turns
22
22
 
23
23
  event_emitter.emit(Events::TurnStart.new)
@@ -86,6 +86,10 @@ module Ask
86
86
  user_tool_calls = response.tool_calls.reject { |id, _| provider_results.key?(id) }
87
87
 
88
88
  if user_tool_calls.any?
89
+ # Repair malformed calls (unparseable arguments, unknown tool
90
+ # names) with one internal LLM round-trip before executing.
91
+ user_tool_calls = repair_malformed_calls(user_tool_calls, tools, chat, event_emitter, tool_call_repair)
92
+
89
93
  # Execute user tool calls locally
90
94
  # Respect the session's parallel_tools setting: parallel tools run
91
95
  # in threads (with the caller's thread-local context inherited);
@@ -162,7 +166,8 @@ module Ask
162
166
  hooks: hooks,
163
167
  event_emitter: event_emitter,
164
168
  session_id: session_id,
165
- persist: persist
169
+ persist: persist,
170
+ tool_call_repair: tool_call_repair
166
171
  )
167
172
  end
168
173
 
@@ -174,6 +179,43 @@ module Ask
174
179
 
175
180
  private
176
181
 
182
+ # Repair malformed tool calls before execution. Calls with corrected
183
+ # versions execute in place of the originals (same ids); calls the
184
+ # model could not correct are dropped — the model saw them in the
185
+ # repair prompt. Best-effort: a failing repair round-trip drops the
186
+ # malformed calls instead of failing the turn.
187
+ def repair_malformed_calls(user_tool_calls, tools, chat, event_emitter, tool_call_repair)
188
+ return user_tool_calls if tool_call_repair.nil? || user_tool_calls.empty?
189
+
190
+ repairer = if tool_call_repair == true
191
+ @tool_call_repair ||= ToolCallRepair.new
192
+ elsif tool_call_repair.respond_to?(:call)
193
+ ToolCallRepair.new(tool_call_repair)
194
+ end
195
+ return user_tool_calls unless repairer
196
+
197
+ malformed, _valid = user_tool_calls.partition do |_id, tc|
198
+ ToolCallRepair.repair_info(tc, tools)
199
+ end
200
+ return user_tool_calls if malformed.empty?
201
+
202
+ corrections = repairer.call(chat: chat, calls: malformed.to_h, tools: tools)
203
+
204
+ corrections.each do |id, corrected|
205
+ event_emitter.emit(Events::ToolCallRepaired.new(
206
+ name: corrected.name,
207
+ id: id,
208
+ original_arguments: user_tool_calls[id].arguments,
209
+ corrected_arguments: corrected.arguments
210
+ ))
211
+ user_tool_calls[id] = corrected
212
+ end
213
+
214
+ malformed_ids = malformed.map(&:first)
215
+ user_tool_calls.reject! { |id, _| malformed_ids.include?(id) && !corrections.key?(id) }
216
+ user_tool_calls
217
+ end
218
+
177
219
  # Whether the session asked the loop to stop (barge-in). Emitters that
178
220
  # don't support aborting (plain stubs) are treated as never aborted.
179
221
  def aborted?(event_emitter)
@@ -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
@@ -22,7 +22,8 @@ module Ask
22
22
  id: nil, system_prompt: nil, parallel_tools: true,
23
23
  reflector: nil, telemetry: true, meta_agent: nil,
24
24
  agent_dir: nil, evaluator: nil, audit_log: nil,
25
- skills_disclosure: true, approval: nil, **chat_options)
25
+ skills_disclosure: true, approval: nil,
26
+ tool_call_repair: nil, **chat_options)
26
27
  @id = id || SecureRandom.uuid
27
28
  @agent_dir = agent_dir
28
29
  @max_turns = max_turns
@@ -54,6 +55,7 @@ module Ask
54
55
  @hooks = Hooks.new(hooks)
55
56
  @audit_log = build_audit_log(audit_log)
56
57
  @approval_queue = build_approval(approval)
58
+ @tool_call_repair = tool_call_repair
57
59
 
58
60
  @system_context = build_system_context(system_prompt)
59
61
  apply_system_context
@@ -129,6 +131,7 @@ module Ask
129
131
  hooks: @hooks,
130
132
  event_emitter: self,
131
133
  session_id: @id,
134
+ tool_call_repair: @tool_call_repair,
132
135
  persist: @state ? method(:persist!) : nil
133
136
  )
134
137
 
@@ -202,7 +205,8 @@ module Ask
202
205
  compactor: @compactor,
203
206
  hooks: @hooks,
204
207
  event_emitter: self,
205
- session_id: @id
208
+ session_id: @id,
209
+ tool_call_repair: @tool_call_repair
206
210
  )
207
211
 
208
212
  @total_input_tokens += @loop.last_input_tokens.to_i
@@ -241,7 +245,8 @@ module Ask
241
245
  compactor: @compactor,
242
246
  hooks: @hooks,
243
247
  event_emitter: self,
244
- session_id: @id
248
+ session_id: @id,
249
+ tool_call_repair: @tool_call_repair
245
250
  )
246
251
 
247
252
  @total_input_tokens += @loop.last_input_tokens.to_i
@@ -421,7 +426,7 @@ module Ask
421
426
  def build_audit_log(config)
422
427
  config ||= Ask::Agent.configuration.audit_log
423
428
  return nil unless config
424
- Ask::Agent::Extensions::AuditLog.new(self, adapter: config)
429
+ Ask::Agent::Policies::AuditLog.new(self, adapter: config)
425
430
  end
426
431
 
427
432
  # Build the approval queue + policy when approval is enabled.
@@ -451,7 +456,7 @@ module Ask
451
456
  )
452
457
  end
453
458
 
454
- policy = Ask::Agent::Extensions::ApprovalPolicy.new(
459
+ policy = Ask::Agent::Policies::ApprovalPolicy.new(
455
460
  queue: queue,
456
461
  require_approval: policy_opts[:require_approval],
457
462
  tools: @tools
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Agent
7
+ # Repairs malformed tool calls before they execute.
8
+ #
9
+ # When a model emits a tool call with unparseable arguments or an unknown
10
+ # tool name, execution currently fails and the model burns a turn seeing
11
+ # the error. Repair intercepts those calls before execution, asks the
12
+ # model to re-emit them corrected (one internal LLM round-trip), and
13
+ # executes the corrected versions — remapped to the original call ids so
14
+ # tool results stay consistent with the conversation history.
15
+ #
16
+ # Enable with `Session.new(tool_call_repair: true)` for the built-in
17
+ # repair prompt, or pass a callable for full control:
18
+ #
19
+ # Session.new(tool_call_repair: ->(chat, calls, tools) {
20
+ # { calls.keys.first => ToolCallInfo.new(
21
+ # id: calls.keys.first, name: "bash", arguments: "{}"
22
+ # ) }
23
+ # })
24
+ #
25
+ # The callable receives the chat, the malformed calls hash (id =>
26
+ # ToolCallInfo), and the available tools; it returns a hash of
27
+ # corrections keyed by the ORIGINAL call ids. Calls without a correction
28
+ # are dropped — the model saw them in the prompt and declined to fix
29
+ # them.
30
+ class ToolCallRepair
31
+ # @param tool_call [ToolCallInfo]
32
+ # @param tools [Array<Object>]
33
+ # @return [String, nil] why the call is repairable, or nil when it is
34
+ # well-formed and executable
35
+ def self.repair_info(tool_call, tools)
36
+ unless tools.any? { |t| t.respond_to?(:name) && t.name == tool_call.name }
37
+ return "unknown tool '#{tool_call.name}'"
38
+ end
39
+
40
+ args = tool_call.arguments
41
+ return nil if args.is_a?(Hash)
42
+
43
+ parsed = JSON.parse(args.to_s)
44
+ return nil if parsed.is_a?(Hash)
45
+
46
+ "arguments must be a JSON object, got #{parsed.class}"
47
+ rescue JSON::ParserError => e
48
+ "arguments are not valid JSON: #{e.message}"
49
+ end
50
+
51
+ # @param callable [Proc, nil] custom repair function; nil uses the
52
+ # built-in repair prompt
53
+ def initialize(callable = nil)
54
+ @callable = callable
55
+ end
56
+
57
+ # Ask the model to correct the malformed calls.
58
+ #
59
+ # @param chat [Ask::Agent::Chat] the session chat (history is restored
60
+ # after the internal round-trip)
61
+ # @param calls [Hash{String => ToolCallInfo}] malformed calls by id
62
+ # @param tools [Array<Object>]
63
+ # @return [Hash{String => ToolCallInfo}] corrections keyed by the
64
+ # original call ids
65
+ def call(chat:, calls:, tools:)
66
+ if @callable
67
+ normalize(@callable.call(chat, calls, tools))
68
+ else
69
+ built_in(chat, calls, tools)
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def built_in(chat, calls, tools)
76
+ size = chat.messages.size
77
+ response = chat.ask(repair_prompt(calls, tools))
78
+ # Remove the internal repair exchange from the conversation so the
79
+ # history stays clean and the model never sees it.
80
+ chat.messages.slice!(size..)
81
+
82
+ corrections = {}
83
+ response.tool_calls.values.each_with_index do |tc, index|
84
+ original_id = calls.keys[index]
85
+ break unless original_id
86
+
87
+ corrections[original_id] = ToolCallInfo.new(
88
+ id: original_id, name: tc.name, arguments: tc.arguments
89
+ )
90
+ end
91
+ corrections
92
+ rescue StandardError
93
+ # Repair is best-effort: on any failure, drop the malformed calls
94
+ # rather than failing the turn. Restore history either way.
95
+ chat.messages.slice!(size..) rescue nil
96
+ {}
97
+ end
98
+
99
+ # Normalize a custom callable's result to the corrections contract
100
+ # (original id => ToolCallInfo), dropping anything malformed.
101
+ def normalize(result)
102
+ return {} unless result.respond_to?(:each)
103
+
104
+ result.each_with_object({}) do |(id, tc), acc|
105
+ next unless tc.respond_to?(:name)
106
+
107
+ acc[id] = ToolCallInfo.new(id: id, name: tc.name, arguments: tc.arguments)
108
+ end
109
+ end
110
+
111
+ def repair_prompt(calls, tools)
112
+ lines = calls.map.with_index do |(id, tc), i|
113
+ reason = self.class.repair_info(tc, tools) || "invalid"
114
+ "#{i + 1}. call id \"#{id}\", tool \"#{tc.name}\", " \
115
+ "arguments: #{tc.arguments.inspect} — #{reason}"
116
+ end
117
+ tool_list = tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(", ")
118
+
119
+ <<~PROMPT.strip
120
+ Some tool calls from your last message were invalid and could not be executed:
121
+ #{lines.join("\n")}
122
+
123
+ Available tools: #{tool_list.empty? ? "(none)" : tool_list}
124
+
125
+ Reply by calling the tools again with corrected arguments, in the same order as listed above. Call ONLY the tools listed above that you can correct; if a tool truly does not exist or cannot be corrected, do not call it.
126
+ PROMPT
127
+ end
128
+ end
129
+ end
130
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.27.1"
5
+ VERSION = "0.29.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -21,13 +21,24 @@ 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
 
40
+ autoload :ToolCallRepair, "ask/agent/tool_call_repair"
41
+
31
42
  module Middleware
32
43
  autoload :Base, "ask/agent/middleware/base"
33
44
  autoload :Pipeline, "ask/agent/middleware/pipeline"
@@ -236,8 +247,8 @@ module Ask
236
247
  yield configuration
237
248
  end
238
249
 
239
- def self.load_extensions
240
- Dir[File.expand_path("agent/extensions/*.rb", __dir__)].each { |f| require f }
250
+ def self.load_policies
251
+ Dir[File.expand_path("agent/policies/*.rb", __dir__)].each { |f| require f }
241
252
  rescue Errno::ENOENT
242
253
  end
243
254
  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.1
4
+ version: 0.29.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
@@ -204,6 +204,7 @@ files:
204
204
  - lib/ask/agent/telemetry.rb
205
205
  - lib/ask/agent/test.rb
206
206
  - lib/ask/agent/tool_abort_controller.rb
207
+ - lib/ask/agent/tool_call_repair.rb
207
208
  - lib/ask/agent/tool_executor.rb
208
209
  - lib/ask/agent/version.rb
209
210
  homepage: https://github.com/ask-rb/ask-agent