Skip to content

Commit 823c370

Browse files
authored
Improve openai_ask tool-use behavior (#719)
2 parents b17a415 + 7359614 commit 823c370

3 files changed

Lines changed: 385 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ _None_
1414

1515
### Bug Fixes
1616

17-
_None_
17+
- `openai_ask`: avoid logging sensitive tool diagnostics and refuse to execute additional tool calls after `max_tool_iterations`. [#719]
1818

1919
### Internal Changes
2020

21-
_None_
21+
- `openai_ask`: validate named function tools, default to `gpt-4.1`, use `max_completion_tokens`, and opt out of OpenAI request storage. [#719]
2222

2323
## 14.6.0
2424

lib/fastlane/plugin/wpmreleasetoolkit/actions/common/openai_ask_action.rb

Lines changed: 116 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ module Fastlane
88
module Actions
99
class OpenaiAskAction < Action
1010
OPENAI_API_ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze
11+
# Preserve the previous `max_tokens` ceiling while using the current API field.
12+
DEFAULT_MAX_COMPLETION_TOKENS = 2048
1113
DEFAULT_MAX_TOOL_ITERATIONS = 5
12-
DEFAULT_MODEL = 'gpt-4o'
14+
DEFAULT_MODEL = 'gpt-4.1'
1315

1416
PREDEFINED_PROMPTS = {
1517
release_notes: <<~PROMPT
@@ -39,12 +41,16 @@ def self.run(params)
3941
}
4042

4143
# Backwards-compatible single-shot path when no tools are provided.
42-
if tools.nil? || tools.empty?
44+
if tools.nil?
4345
body = request_body(prompt: prompt, question: question, model: model)
4446
response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
4547
return parse_text_response(response)
4648
end
4749

50+
validate_tools_array!(tools)
51+
validate_max_tool_iterations!(max_tool_iterations)
52+
validate_tools!(tools)
53+
4854
run_with_tools(
4955
prompt: prompt,
5056
question: question,
@@ -62,7 +68,9 @@ def self.run_with_tools(prompt:, question:, model:, tools:, tool_handlers:, max_
6268
format_message(role: 'user', text: question),
6369
].compact
6470

65-
max_tool_iterations.times do
71+
tool_iterations = 0
72+
73+
loop do
6674
body = request_body_with_messages(messages: messages, tools: tools, model: model)
6775
response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
6876
assistant_message = parse_assistant_message(response)
@@ -71,26 +79,31 @@ def self.run_with_tools(prompt:, question:, model:, tools:, tool_handlers:, max_
7179
# No tool calls — model produced a final answer.
7280
return assistant_message['content'] if tool_calls.nil? || tool_calls.empty?
7381

82+
if tool_iterations >= max_tool_iterations
83+
UI.user_error!(
84+
"OpenAI tool-use loop did not produce a final answer after #{max_tool_iterations} tool iterations. " \
85+
'Refusing to execute additional tool calls. Increase `max_tool_iterations` or check that your prompt instructs the model to stop calling tools.'
86+
)
87+
end
88+
7489
# Append the assistant's tool-call message verbatim, then run each handler
7590
# and append the corresponding `role: tool` results.
7691
messages << assistant_message
7792
tool_calls.each do |tool_call|
7893
messages << execute_tool_call(tool_call, tool_handlers)
7994
end
80-
end
8195

82-
UI.user_error!(
83-
"OpenAI tool-use loop did not terminate after #{max_tool_iterations} iterations. " \
84-
'Increase `max_tool_iterations` or check that your prompt instructs the model to stop calling tools.'
85-
)
96+
tool_iterations += 1
97+
end
8698
end
8799

88100
def self.request_body(prompt:, question:, model: DEFAULT_MODEL)
89101
{
90102
model: model,
103+
store: false,
91104
response_format: { type: 'text' },
92105
temperature: 1,
93-
max_tokens: 2048,
106+
max_completion_tokens: DEFAULT_MAX_COMPLETION_TOKENS,
94107
top_p: 1,
95108
messages: [
96109
format_message(role: 'system', text: prompt),
@@ -102,9 +115,10 @@ def self.request_body(prompt:, question:, model: DEFAULT_MODEL)
102115
def self.request_body_with_messages(messages:, tools:, model: DEFAULT_MODEL)
103116
{
104117
model: model,
118+
store: false,
105119
response_format: { type: 'text' },
106120
temperature: 1,
107-
max_tokens: 2048,
121+
max_completion_tokens: DEFAULT_MAX_COMPLETION_TOKENS,
108122
top_p: 1,
109123
messages: messages,
110124
tools: tools
@@ -140,8 +154,49 @@ def self.parse_assistant_message(response)
140154
end
141155
end
142156

157+
def self.validate_max_tool_iterations!(max_tool_iterations)
158+
UI.user_error!("Parameter `max_tool_iterations` must be an Integer (got #{max_tool_iterations.class})") unless max_tool_iterations.is_a?(Integer)
159+
UI.user_error!("Parameter `max_tool_iterations` must be >= 1 (got #{max_tool_iterations})") if max_tool_iterations < 1
160+
end
161+
162+
def self.validate_tools_array!(tools)
163+
UI.user_error!('Parameter `tools` must be a non-empty Array when provided') unless tools.is_a?(Array) && !tools.empty?
164+
end
165+
166+
def self.validate_tools!(tools)
167+
invalid_tools = tools.each_with_index.filter_map do |tool, index|
168+
type = tool_type(tool)
169+
next "tools[#{index}] type #{type.nil? ? '<missing>' : type.inspect}" unless type == 'function'
170+
171+
function = tool[:function] || tool['function']
172+
name = function[:name] || function['name'] if function.is_a?(Hash)
173+
next if valid_tool_name?(name)
174+
175+
"tools[#{index}] missing function.name"
176+
end
177+
178+
return if invalid_tools.empty?
179+
180+
UI.user_error!(
181+
'Parameter `tools` only supports OpenAI function tools with a non-empty `function.name`. ' \
182+
"Invalid tool definitions: #{invalid_tools.join(', ')}"
183+
)
184+
end
185+
186+
def self.tool_type(tool)
187+
return nil unless tool.is_a?(Hash)
188+
189+
(tool[:type] || tool['type'])&.to_s
190+
end
191+
192+
def self.valid_tool_name?(name)
193+
(name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.empty?
194+
end
195+
143196
def self.execute_tool_call(tool_call, tool_handlers)
144-
name = tool_call.dig('function', 'name')
197+
return unsupported_tool_call_result(tool_call) unless function_tool_call?(tool_call)
198+
199+
name = tool_call.dig('function', 'name').to_s
145200
raw_args = tool_call.dig('function', 'arguments') || '{}'
146201

147202
result =
@@ -151,8 +206,8 @@ def self.execute_tool_call(tool_call, tool_handlers)
151206
rescue JSON::ParserError
152207
# Short-circuit: the handler never sees malformed args. Tell the model the
153208
# tool-call payload was invalid so it can retry with valid JSON, and log the
154-
# raw arguments locally for debugging without forwarding them to the API.
155-
UI.error("Invalid JSON arguments for tool '#{name}'. Raw payload: #{raw_args}")
209+
# local failure without recording raw arguments that might contain secrets.
210+
UI.error("Invalid JSON arguments for tool '#{name}' in tool call '#{tool_call['id']}'. Raw payload omitted because it may contain secrets.")
156211
{ error: "Invalid JSON arguments for tool '#{name}' — payload could not be parsed. Retry with valid JSON." }
157212
end
158213

@@ -163,6 +218,40 @@ def self.execute_tool_call(tool_call, tool_handlers)
163218
}
164219
end
165220

221+
def self.function_tool_call?(tool_call)
222+
return false unless tool_call['type'] == 'function'
223+
return false unless tool_call['function'].is_a?(Hash)
224+
225+
name = tool_call.dig('function', 'name')
226+
valid_tool_name?(name)
227+
end
228+
229+
def self.unsupported_tool_call_result(tool_call)
230+
type = tool_call['type'] || '<missing>'
231+
error =
232+
if type == 'function'
233+
'Function tool call is missing a non-empty function.name.'
234+
else
235+
"Unsupported tool call type '#{type}'. Only function tool calls are supported."
236+
end
237+
log_message =
238+
if type == 'function'
239+
"Invalid OpenAI function tool call '#{tool_call['id']}': missing a non-empty function.name."
240+
else
241+
"Unsupported OpenAI tool call type '#{type}' in tool call '#{tool_call['id']}'. Only function tool calls are supported."
242+
end
243+
UI.error(log_message)
244+
245+
{
246+
role: 'tool',
247+
tool_call_id: tool_call['id'],
248+
content: serialize_tool_result(
249+
name: type,
250+
result: { error: error }
251+
)
252+
}
253+
end
254+
166255
# Serializes a tool result to a JSON string. Handlers are contracted to return
167256
# JSON-serializable values, but a buggy handler might return something like a
168257
# `Pathname`, `Proc`, or a custom object whose `to_json` raises. Failing the
@@ -175,7 +264,7 @@ def self.execute_tool_call(tool_call, tool_handlers)
175264
def self.serialize_tool_result(name:, result:)
176265
JSON.generate(result)
177266
rescue StandardError => e
178-
UI.error("Could not serialize tool result for '#{name}': #{e.class}: #{e.message}. Result class: #{result.class}")
267+
UI.error("Could not serialize tool result for '#{name}': #{e.class}. Result class: #{result.class}. Error message omitted because it may contain secrets.")
179268
JSON.generate({ error: "Tool result for '#{name}' could not be serialized to JSON. Returned class: #{result.class}." })
180269
end
181270

@@ -185,9 +274,9 @@ def self.serialize_tool_result(name:, result:)
185274
#
186275
# - Missing or non-callable handler: structured `{ error: ... }` so the model can recover.
187276
# - Handler raised: structured `{ error:, exception: }` carrying only the exception class
188-
# so the model can see the failure category and adjust. The full message and backtrace
189-
# are logged locally via `UI.error` but NOT forwarded to the model, because tool
190-
# results are sent to OpenAI and handler exception messages can contain secrets
277+
# so the model can see the failure category and adjust. The exception message and
278+
# backtrace are intentionally omitted from local logs and from the model response
279+
# because tool results and CI logs can expose release secrets
191280
# (tokens, file contents, internal API responses). The loop keeps going rather than
192281
# aborting the lane mid-conversation — the model is the better judge of whether the
193282
# failure is recoverable than a global `rescue` here.
@@ -198,7 +287,7 @@ def self.invoke_tool_handler(name:, handler:, args:)
198287
begin
199288
handler.call(args)
200289
rescue StandardError => e
201-
UI.error("Handler for tool '#{name}' raised #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
290+
UI.error("Handler for tool '#{name}' raised #{e.class}. Error message and backtrace omitted because they may contain secrets.")
202291
{ error: "Handler for tool '#{name}' raised an exception", exception: e.class.name }
203292
end
204293
end
@@ -228,7 +317,8 @@ def self.details
228317
When `tools` and `tool_handlers` are provided, the action runs a tool-use (function-calling) loop:
229318
on each turn, if the model calls one or more tools, the corresponding handler is invoked locally
230319
and its return value is sent back to the model as a `role: tool` message. The loop ends when the
231-
model returns a plain text response, or when `max_tool_iterations` is reached.
320+
model returns a plain text response, or before executing tool calls beyond `max_tool_iterations`.
321+
The model gets one final API turn to answer after the last permitted local tool execution round.
232322
DETAILS
233323
end
234324

@@ -306,19 +396,20 @@ def self.available_options
306396
sensitive: true,
307397
type: String),
308398
FastlaneCore::ConfigItem.new(key: :model,
309-
description: 'The OpenAI model to send the request to (e.g. `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`). ' \
399+
description: 'The OpenAI model to send the request to (e.g. `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`). ' \
310400
"Defaults to `#{DEFAULT_MODEL}`",
311401
optional: true,
312402
default_value: DEFAULT_MODEL,
313403
type: String),
314404
FastlaneCore::ConfigItem.new(key: :tools,
315-
description: 'Optional array of tool (function-calling) definitions in OpenAI format. ' \
405+
description: 'Optional array of OpenAI function tool definitions. Each definition must have a non-empty `function.name`. ' \
316406
'When provided, the action runs a tool-use loop',
317407
optional: true,
318408
default_value: nil,
319409
type: Array,
320410
verify_block: proc do |value|
321-
UI.user_error!('Parameter `tools` must be a non-empty Array when provided') if value.empty?
411+
validate_tools_array!(value)
412+
validate_tools!(value)
322413
end),
323414
FastlaneCore::ConfigItem.new(key: :tool_handlers,
324415
description: 'Hash of tool name to a callable (e.g. a Proc) invoked when the model calls that tool. ' \
@@ -332,13 +423,14 @@ def self.available_options
332423
UI.user_error!("Parameter `tool_handlers` values must respond to :call. Non-callable handlers: #{non_callable.keys}") if non_callable.any?
333424
end),
334425
FastlaneCore::ConfigItem.new(key: :max_tool_iterations,
335-
description: 'Maximum number of tool-use loop iterations before the action fails. ' \
426+
description: 'Maximum number of local tool execution rounds before the action fails. ' \
427+
'The model can receive one final API turn to answer after the last permitted tool result. ' \
336428
'Only used when `tools` are provided',
337429
optional: true,
338430
default_value: DEFAULT_MAX_TOOL_ITERATIONS,
339431
type: Integer,
340432
verify_block: proc do |value|
341-
UI.user_error!("Parameter `max_tool_iterations` must be >= 1 (got #{value})") if value < 1
433+
validate_max_tool_iterations!(value)
342434
end),
343435
]
344436
end

0 commit comments

Comments
 (0)