ask-agent 0.28.0 → 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: 36e26d74ffa1c3e3e97b8e172f4fef2ada07c1c194488a8f550e8b77b7473ee1
4
- data.tar.gz: 82568a4cc3ed19617293416d10f2f3194c1a18079a1426431a2d878afee75fa0
3
+ metadata.gz: 11cf486fa2d955d92cc6f8268f30e5a418b6f0567cdfb6fe6eac015a3352c74b
4
+ data.tar.gz: 5b4990efc0b66de943f6e5c3b7940d31cca6295c54c40fafef485c6004433601
5
5
  SHA512:
6
- metadata.gz: cfd202f83bb95e3349282c3771e591f93062976b58c964d65e928e09a6408818ef969200c10b68c4a307e0c0d9fabde112aaf20b9776110a5040590370ecd317
7
- data.tar.gz: 7d3ae82b872659e9e27217bc98cf7dfffee28f5209ee5f351dcc944564a63a777888fdad780de218956019d38f223996ebd6372c7be84cf33fdbf9a1eeb4e5a7
6
+ metadata.gz: 3adf87ccd9b0cbb606133cb91b8baf9e24a328f96e4ed8f77266258883b13663762cbd16a3e1e34475ec0b93ab763518c4b661fa675fba37985144c01435a736
7
+ data.tar.gz: b6093801427cf4c8bc56fd9b6b0bf6f9fd6bc5252ce336731149413984b5f83862e4cfeae2e9705b2517bb73e3063091950293f74cdb32109624965e52ccc760
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
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
+
1
21
  ## [0.28.0] — 2026-08-06
2
22
 
3
23
  ### Changed (breaking)
@@ -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)
@@ -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
@@ -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.28.0"
5
+ VERSION = "0.29.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -37,6 +37,8 @@ module Ask
37
37
  autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
38
38
  end
39
39
 
40
+ autoload :ToolCallRepair, "ask/agent/tool_call_repair"
41
+
40
42
  module Middleware
41
43
  autoload :Base, "ask/agent/middleware/base"
42
44
  autoload :Pipeline, "ask/agent/middleware/pipeline"
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.28.0
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -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