@@ -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