5656//! tests) stay decoupled from the strict-mode adapter — wiring those in
5757//! is a future cleanup once the multi-step path is live.
5858
59+ use std:: collections:: HashSet ;
60+
5961use onwards:: { ChainStep , NextAction , StepDescriptor , StepKind , StepState } ;
6062use serde_json:: { Value , json} ;
6163
@@ -239,10 +241,16 @@ pub(crate) fn prepare_followup_model_call(parsed: &ParsedRequest, chain: &[Chain
239241/// Decide the next action given:
240242/// - `parsed`: the parent fusillade request body
241243/// - `chain`: completed/failed steps in the current scope, in sequence order
244+ /// - `resolved_tool_names`: names of server-side tools registered for this
245+ /// request. Tool_calls whose name is in this set get auto-dispatched as
246+ /// server-side `ToolCall` steps; tool_calls outside the set are
247+ /// passed through to the client as `function_call` output items by
248+ /// completing the response with the model's payload (the assembly step
249+ /// surfaces them per the OpenAI Responses contract).
242250///
243251/// Returns the action the loop should take. Pure function over its inputs;
244252/// no I/O.
245- pub ( crate ) fn decide_next_action ( parsed : & ParsedRequest , chain : & [ ChainStep ] ) -> NextAction {
253+ pub ( crate ) fn decide_next_action ( parsed : & ParsedRequest , chain : & [ ChainStep ] , resolved_tool_names : & HashSet < String > ) -> NextAction {
246254 if chain. is_empty ( ) {
247255 return NextAction :: AppendSteps ( vec ! [ prepare_initial_model_call( parsed) ] ) ;
248256 }
@@ -275,9 +283,31 @@ pub(crate) fn decide_next_action(parsed: &ParsedRequest, chain: &[ChainStep]) ->
275283 let tool_calls = extract_tool_calls ( & response) ;
276284 if tool_calls. is_empty ( ) {
277285 // No tool calls — the model returned final output.
278- NextAction :: Complete ( response)
279- } else {
286+ return NextAction :: Complete ( response) ;
287+ }
288+
289+ // Server-side dispatch is only safe when every tool_call
290+ // names a server-registered tool (i.e., one with a row in
291+ // `tool_sources` for this request's user/deployment). If
292+ // any name is unregistered, it's a client-side function
293+ // tool — the model must have seen it because the user put
294+ // it in the request body — and we cannot dispatch it. The
295+ // OpenAI Responses contract for that case is to surface
296+ // every tool_call as a `function_call` output item and let
297+ // the client run them and submit results in a follow-up.
298+ //
299+ // We bail out for the *whole* fan-out (rather than partial
300+ // dispatch) because the model expects results for every
301+ // call it emitted before producing its next message; a
302+ // mixed dispatch would leave the conversation in a state
303+ // the upstream model can't reason about.
304+ let all_registered = tool_calls
305+ . iter ( )
306+ . all ( |step| tool_call_name ( & step. request_payload ) . is_some_and ( |name| resolved_tool_names. contains ( name) ) ) ;
307+ if all_registered {
280308 NextAction :: AppendSteps ( tool_calls)
309+ } else {
310+ NextAction :: Complete ( response)
281311 }
282312 }
283313 StepKind :: ToolCall => {
@@ -290,6 +320,10 @@ pub(crate) fn decide_next_action(parsed: &ParsedRequest, chain: &[ChainStep]) ->
290320 }
291321}
292322
323+ fn tool_call_name ( payload : & Value ) -> Option < & str > {
324+ payload. get ( "name" ) . and_then ( |n| n. as_str ( ) )
325+ }
326+
293327#[ cfg( test) ]
294328mod tests {
295329 use super :: * ;
@@ -322,6 +356,10 @@ mod tests {
322356 assert_eq ! ( p. initial_messages. len( ) , 1 ) ;
323357 }
324358
359+ fn names ( items : & [ & str ] ) -> HashSet < String > {
360+ items. iter ( ) . map ( |s| s. to_string ( ) ) . collect ( )
361+ }
362+
325363 #[ test]
326364 fn empty_chain_emits_initial_model_call ( ) {
327365 let parsed = ParsedRequest {
@@ -330,7 +368,7 @@ mod tests {
330368 tools : None ,
331369 stream : false ,
332370 } ;
333- match decide_next_action ( & parsed, & [ ] ) {
371+ match decide_next_action ( & parsed, & [ ] , & HashSet :: new ( ) ) {
334372 NextAction :: AppendSteps ( steps) => {
335373 assert_eq ! ( steps. len( ) , 1 ) ;
336374 assert ! ( matches!( steps[ 0 ] . kind, StepKind :: ModelCall ) ) ;
@@ -341,7 +379,7 @@ mod tests {
341379 }
342380
343381 #[ test]
344- fn model_call_with_tool_calls_emits_fan_out ( ) {
382+ fn model_call_with_registered_tool_calls_emits_fan_out ( ) {
345383 let parsed = ParsedRequest {
346384 model : "m" . into ( ) ,
347385 initial_messages : vec ! [ ] ,
@@ -360,7 +398,7 @@ mod tests {
360398 } ]
361399 } ) ;
362400 let chain = vec ! [ step( "s1" , 1 , StepKind :: ModelCall , StepState :: Completed , Some ( response) ) ] ;
363- match decide_next_action ( & parsed, & chain) {
401+ match decide_next_action ( & parsed, & chain, & names ( & [ "a" , "b" ] ) ) {
364402 NextAction :: AppendSteps ( steps) => {
365403 assert_eq ! ( steps. len( ) , 2 ) ;
366404 assert_eq ! ( steps[ 0 ] . request_payload[ "name" ] , "a" ) ;
@@ -372,6 +410,67 @@ mod tests {
372410 }
373411 }
374412
413+ #[ test]
414+ fn model_call_with_unregistered_tool_completes_for_client_dispatch ( ) {
415+ // The user supplied a client-side function tool in the request
416+ // body; the model emits a tool_call for it. With no row in
417+ // `tool_sources` for this name, the loop must NOT try to
418+ // dispatch it (HttpToolExecutor would fail with NotFound) —
419+ // instead it completes with the model's response so assembly
420+ // can surface a `function_call` output item to the client.
421+ let parsed = ParsedRequest {
422+ model : "m" . into ( ) ,
423+ initial_messages : vec ! [ ] ,
424+ tools : None ,
425+ stream : false ,
426+ } ;
427+ let response = json ! ( {
428+ "choices" : [ {
429+ "message" : {
430+ "role" : "assistant" ,
431+ "tool_calls" : [
432+ { "id" : "call_1" , "type" : "function" , "function" : { "name" : "read_pages" , "arguments" : "{\" id\" :1}" } } ,
433+ ]
434+ }
435+ } ]
436+ } ) ;
437+ let chain = vec ! [ step( "s1" , 1 , StepKind :: ModelCall , StepState :: Completed , Some ( response. clone( ) ) ) ] ;
438+ match decide_next_action ( & parsed, & chain, & HashSet :: new ( ) ) {
439+ NextAction :: Complete ( v) => assert_eq ! ( v, response) ,
440+ other => panic ! ( "expected Complete for unregistered tool, got {other:?}" ) ,
441+ }
442+ }
443+
444+ #[ test]
445+ fn model_call_with_mixed_registered_and_unregistered_completes ( ) {
446+ // If even one tool_call in a fan-out is unregistered, the whole
447+ // batch passes through to the client. Partial dispatch would
448+ // leave the model expecting results for tool_calls the loop
449+ // never ran.
450+ let parsed = ParsedRequest {
451+ model : "m" . into ( ) ,
452+ initial_messages : vec ! [ ] ,
453+ tools : None ,
454+ stream : false ,
455+ } ;
456+ let response = json ! ( {
457+ "choices" : [ {
458+ "message" : {
459+ "role" : "assistant" ,
460+ "tool_calls" : [
461+ { "id" : "call_1" , "type" : "function" , "function" : { "name" : "weather" , "arguments" : "{}" } } ,
462+ { "id" : "call_2" , "type" : "function" , "function" : { "name" : "client_only" , "arguments" : "{}" } } ,
463+ ]
464+ }
465+ } ]
466+ } ) ;
467+ let chain = vec ! [ step( "s1" , 1 , StepKind :: ModelCall , StepState :: Completed , Some ( response. clone( ) ) ) ] ;
468+ match decide_next_action ( & parsed, & chain, & names ( & [ "weather" ] ) ) {
469+ NextAction :: Complete ( v) => assert_eq ! ( v, response) ,
470+ other => panic ! ( "expected Complete for mixed tool_calls, got {other:?}" ) ,
471+ }
472+ }
473+
375474 #[ test]
376475 fn model_call_without_tool_calls_completes ( ) {
377476 let parsed = ParsedRequest {
@@ -386,7 +485,7 @@ mod tests {
386485 } ]
387486 } ) ;
388487 let chain = vec ! [ step( "s1" , 1 , StepKind :: ModelCall , StepState :: Completed , Some ( response. clone( ) ) ) ] ;
389- match decide_next_action ( & parsed, & chain) {
488+ match decide_next_action ( & parsed, & chain, & HashSet :: new ( ) ) {
390489 NextAction :: Complete ( v) => assert_eq ! ( v, response) ,
391490 _ => panic ! ( "expected Complete" ) ,
392491 }
@@ -412,7 +511,7 @@ mod tests {
412511 step( "s1" , 1 , StepKind :: ModelCall , StepState :: Completed , Some ( model_response) ) ,
413512 step( "s2" , 2 , StepKind :: ToolCall , StepState :: Completed , Some ( json!( { "result" : 1 } ) ) ) ,
414513 ] ;
415- match decide_next_action ( & parsed, & chain) {
514+ match decide_next_action ( & parsed, & chain, & names ( & [ "a" ] ) ) {
416515 NextAction :: AppendSteps ( steps) => {
417516 assert_eq ! ( steps. len( ) , 1 ) ;
418517 assert ! ( matches!( steps[ 0 ] . kind, StepKind :: ModelCall ) ) ;
@@ -437,7 +536,7 @@ mod tests {
437536 } ;
438537 let mut s = step ( "s1" , 1 , StepKind :: ModelCall , StepState :: Failed , None ) ;
439538 s. error = Some ( json ! ( { "type" : "upstream_500" } ) ) ;
440- match decide_next_action ( & parsed, & [ s] ) {
539+ match decide_next_action ( & parsed, & [ s] , & HashSet :: new ( ) ) {
441540 NextAction :: Fail ( v) => assert_eq ! ( v, json!( { "type" : "upstream_500" } ) ) ,
442541 _ => panic ! ( "expected Fail" ) ,
443542 }
0 commit comments