@@ -141,8 +141,11 @@ pub async fn run_agent_loop(
141141 }
142142 }
143143
144- let needs_schema_in_prompt =
145- structured_output == Some ( StructuredOutputSupport :: JsonObjectOnly ) && response_schema. is_some ( ) ;
144+ let has_final_message_tool = response_schema. is_some ( ) ;
145+
146+ let needs_schema_in_prompt = structured_output == Some ( StructuredOutputSupport :: JsonObjectOnly )
147+ && response_schema. is_some ( )
148+ && !has_final_message_tool;
146149
147150 if messages. is_empty ( ) {
148151 let mut sys = system_prompt. to_string ( ) ;
@@ -168,9 +171,37 @@ pub async fn run_agent_loop(
168171 tool_call_id : None ,
169172 } ) ;
170173
174+ let mut tools_with_final: Vec < ToolDefinition > = tools. to_vec ( ) ;
175+ if has_final_message_tool {
176+ let schema_desc = response_schema. and_then ( |s| serde_json:: to_string ( s) . ok ( ) ) . unwrap_or_default ( ) ;
177+ tools_with_final. push ( ToolDefinition {
178+ type_ : "function" . to_string ( ) ,
179+ function : FunctionSchema {
180+ name : "final_message_json" . to_string ( ) ,
181+ description : format ! (
182+ "Call this tool to submit your final structured JSON result when your task is complete. \
183+ The message MUST be valid JSON matching this schema: {}",
184+ schema_desc
185+ ) ,
186+ parameters : serde_json:: json!( {
187+ "type" : "object" ,
188+ "properties" : {
189+ "message" : {
190+ "type" : "string" ,
191+ "description" : "Your final result as a JSON string matching the required schema."
192+ }
193+ } ,
194+ "required" : [ "message" ] ,
195+ "additionalProperties" : false
196+ } ) ,
197+ } ,
198+ } ) ;
199+ }
200+ let effective_tools = if has_final_message_tool { & tools_with_final } else { tools } ;
201+
171202 let needs_tool_name_sanitization = model. contains ( "kimi" ) ;
172203 let sanitized_tools: Vec < ToolDefinition > = if needs_tool_name_sanitization {
173- tools
204+ effective_tools
174205 . iter ( )
175206 . map ( |t| {
176207 let mut t = t. clone ( ) ;
@@ -179,9 +210,9 @@ pub async fn run_agent_loop(
179210 } )
180211 . collect ( )
181212 } else {
182- tools . to_vec ( )
213+ effective_tools . to_vec ( )
183214 } ;
184- let api_tools = if needs_tool_name_sanitization { & sanitized_tools } else { tools } ;
215+ let api_tools = if needs_tool_name_sanitization { & sanitized_tools } else { effective_tools } ;
185216
186217 for turn in 0 ..max_turns {
187218 if cancel_token. is_cancelled ( ) {
@@ -197,12 +228,16 @@ pub async fn run_agent_loop(
197228
198229 context:: truncate_to_fit ( & mut messages, context_limit, max_tokens) ;
199230
200- let format = match structured_output {
201- Some ( StructuredOutputSupport :: JsonSchema ) => response_schema. map ( build_json_schema_format) ,
202- Some ( StructuredOutputSupport :: JsonObjectOnly ) if response_schema. is_some ( ) => {
203- Some ( build_json_object_format ( ) )
231+ let format = if has_final_message_tool {
232+ None // Don't send response_format when using final_message_json tool
233+ } else {
234+ match structured_output {
235+ Some ( StructuredOutputSupport :: JsonSchema ) => response_schema. map ( build_json_schema_format) ,
236+ Some ( StructuredOutputSupport :: JsonObjectOnly ) if response_schema. is_some ( ) => {
237+ Some ( build_json_object_format ( ) )
238+ }
239+ _ => None ,
204240 }
205- _ => None ,
206241 } ;
207242
208243 let request = ChatRequest {
@@ -234,6 +269,21 @@ pub async fn run_agent_loop(
234269 }
235270
236271 if !has_tool_calls {
272+ // If final_message_json tool is available but model didn't call it, prompt to call it
273+ if has_final_message_tool && turn < max_turns - 1 {
274+ messages. push ( ChatMessage {
275+ reasoning_content : None ,
276+ role : "user" . to_string ( ) ,
277+ content : Some (
278+ "You must call the final_message_json tool to submit your result. \
279+ Do not respond with plain text — call the final_message_json tool now."
280+ . to_string ( ) ,
281+ ) ,
282+ tool_calls : None ,
283+ tool_call_id : None ,
284+ } ) ;
285+ continue ;
286+ }
237287 output. flush_result ( ) ;
238288 let content = assistant_msg. content . as_deref ( ) . unwrap_or ( "" ) ;
239289 let mut schema_ok = true ;
@@ -279,6 +329,31 @@ pub async fn run_agent_loop(
279329
280330 let tool_calls = assistant_msg. tool_calls . as_ref ( ) . unwrap ( ) ;
281331
332+ // Check if any tool call is final_message_json — if so, emit and stop
333+ if has_final_message_tool {
334+ let final_tc = tool_calls. iter ( ) . find ( |tc| {
335+ let name = if needs_tool_name_sanitization {
336+ tc. function . name . replace ( '_' , "." )
337+ } else {
338+ tc. function . name . clone ( )
339+ } ;
340+ name == "final_message_json" || name == "final.message.json"
341+ } ) ;
342+ if let Some ( tc) = final_tc {
343+ let args: serde_json:: Value =
344+ serde_json:: from_str ( & tc. function . arguments ) . unwrap_or ( serde_json:: Value :: Null ) ;
345+ let message = args. get ( "message" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) ;
346+ output. text_chunk ( message) ;
347+ output. flush_result ( ) ;
348+ if let Some ( sid) = session_id {
349+ let _ = save_session_messages_to ( & config_dir ( ) , sid, & messages) ;
350+ }
351+ output. emit_session_summary ( ) ;
352+ output. newline ( ) ;
353+ return Ok ( ( ) ) ;
354+ }
355+ }
356+
282357 for tc in tool_calls {
283358 if cancel_token. is_cancelled ( ) {
284359 eprintln ! ( "[oai-runner] Cancelled between tool calls" ) ;
0 commit comments