3333from effectful .handlers .llm .evaluation import ReplSession
3434from effectful .handlers .llm .template import (
3535 Agent ,
36+ FinalTool ,
3637 Template ,
3738 Tool ,
3839 _is_recursive_signature ,
@@ -176,9 +177,6 @@ def to_feedback_message(self, include_traceback: bool) -> Message:
176177 )
177178
178179
179- type MessageResult [T ] = tuple [Message , typing .Sequence [DecodedToolCall ], T | None ]
180-
181-
182180class _LexicalVariableTool [T ](Tool [[], T ]):
183181 """A zero-arg `Tool` that returns the captured value of a variable
184182 from a `Template`'s lexical context.
@@ -358,6 +356,166 @@ def _collect(
358356 return tools
359357
360358
359+ @Operation .define
360+ def _synthesis_signature () -> inspect .Signature | None :
361+ """Return the signature of the in-flight Template call, or ``None``.
362+
363+ `SynthesizeAndCall` installs a fresh handler for this inside each
364+ `Template.__apply__` (mirroring `_repl_session`), giving it a lifetime of
365+ exactly one Template call. Outside such a scope there is no synthesis
366+ target, so this falls back to ``None`` -- e.g. when tools are listed outside
367+ a Template call -- and no synthesis tool is injected.
368+ """
369+ return None
370+
371+
372+ def _synthesis_final_tool (
373+ signature : inspect .Signature ,
374+ env : collections .abc .Mapping [str , typing .Any ],
375+ name : str = "submit_solution" ,
376+ ) -> FinalTool :
377+ """Build a :class:`FinalTool` that finalizes a Template by code synthesis.
378+
379+ The tool takes one argument -- a function with the Template's signature,
380+ synthesized from the model's code by the existing ``Encodable[Callable[...]]``
381+ machinery -- and applies it to the original inputs (recovered from ``env``),
382+ returning the value. Because it is a :class:`FinalTool`, calling it
383+ terminates the completion loop and its return value is the Template's result.
384+ """
385+ param_types = []
386+ for pname , param in signature .parameters .items ():
387+ if param .kind in (
388+ inspect .Parameter .VAR_POSITIONAL ,
389+ inspect .Parameter .VAR_KEYWORD ,
390+ ):
391+ raise TypeError (
392+ f"SynthesizeAndCall cannot synthesize a function for parameter "
393+ f"'{ pname } ' of kind { param .kind .description } : variadic parameters "
394+ "cannot be expressed as a Callable type signature."
395+ )
396+ param_types .append (
397+ param .annotation
398+ if param .annotation is not inspect .Parameter .empty
399+ else typing .Any
400+ )
401+ return_type = signature .return_annotation
402+ if return_type is inspect .Signature .empty :
403+ raise TypeError (
404+ "SynthesizeAndCall requires a return annotation on the Template's "
405+ "signature to construct the synthesis tool's Callable type."
406+ )
407+
408+ callable_type = collections .abc .Callable [param_types , return_type ] # type: ignore[valid-type]
409+
410+ # Recover the original arguments from `env` by name, respecting each
411+ # parameter's kind so positional-only and keyword-only parameters bind
412+ # correctly (variadic kinds were rejected above).
413+ pos_names = [
414+ pname
415+ for pname , param in signature .parameters .items ()
416+ if param .kind
417+ in (
418+ inspect .Parameter .POSITIONAL_ONLY ,
419+ inspect .Parameter .POSITIONAL_OR_KEYWORD ,
420+ )
421+ ]
422+ kw_names = [
423+ pname
424+ for pname , param in signature .parameters .items ()
425+ if param .kind is inspect .Parameter .KEYWORD_ONLY
426+ ]
427+
428+ def submit_solution (implementation ):
429+ bound = signature .bind (
430+ * (env [pname ] for pname in pos_names ),
431+ ** {pname : env [pname ] for pname in kw_names },
432+ )
433+ return implementation (* bound .args , ** bound .kwargs )
434+
435+ submit_solution .__name__ = name
436+ submit_solution .__qualname__ = name
437+ submit_solution .__module__ = __name__
438+ submit_solution .__doc__ = (
439+ "Submit your final answer as a Python function implementing the task. "
440+ "The function must have the required signature; it is applied to the "
441+ "original inputs and its return value is your final answer."
442+ )
443+ submit_solution .__annotations__ = {
444+ "implementation" : callable_type ,
445+ "return" : signature .return_annotation ,
446+ }
447+ return FinalTool .define (submit_solution )
448+
449+
450+ class SynthesizeAndCall (ObjectInterpretation ):
451+ """Answer a Template by synthesizing a function and calling it.
452+
453+ Instead of asking the LLM to generate an instance of the Template's return
454+ type directly, this handler exposes a :class:`FinalTool` that lets the model
455+ "answer" by writing a Python function with the Template's signature. The
456+ harness applies that function to the original arguments and its return value
457+ becomes the Template's result. This is the declarative "CodeAdapt" workflow:
458+ the LLM writes code implementing the body of the Template rather than
459+ reasoning out the answer itself.
460+
461+ The synthesis tool is offered *alongside* the Template's normal completion
462+ paths rather than replacing them: across turns the model may freely call any
463+ other tool in scope (their results are fed back as usual), and it may still
464+ answer the return type directly via structured output. The loop terminates
465+ when it either answers directly or calls the synthesis :class:`FinalTool`.
466+ To force the synthesis path, pass ``tool_choice="required"`` (handler config
467+ is forwarded to the model request). The function is synthesized by reusing
468+ the existing ``Callable`` synthesis machinery: the tool's argument is typed
469+ as ``Callable[[params], ret]``, so :func:`call_assistant`'s tool-call
470+ decoding parses, type-checks, compiles and executes the model's code into a
471+ real function before it is applied.
472+
473+ Scoping mirrors :class:`PythonRepl`: this handles `Template.__apply__` to
474+ introduce a fresh `_synthesis_signature` handler bound to that call's
475+ signature, and handles `collect_tools` to inject the synthesis tool built
476+ from it. The synthesis target is therefore introduced and eliminated by its
477+ own handler, bounded to the Template call by construction -- nested Template
478+ calls get their own target.
479+
480+ Failures compose with :class:`RetryLLMHandler`: a function that fails to
481+ synthesize surfaces as a :class:`ToolCallDecodingError`, and one that raises
482+ when applied to the inputs as a :class:`ToolCallExecutionError`; both are fed
483+ back to the model as a tool message and the loop continues so it can revise::
484+
485+ with (
486+ handler(LiteLLMProvider(model="gpt-5-mini")),
487+ handler(SynthesizeAndCall()),
488+ handler(RetryLLMHandler()),
489+ ):
490+ ...
491+
492+ Requires an eval provider (e.g. :class:`UnsafeEvalProvider` or
493+ :class:`RestrictedEvalProvider`) to be installed so the synthesized code can
494+ be compiled and executed.
495+ """
496+
497+ @implements (Template .__apply__ )
498+ def _apply [** P , T ](
499+ self , template : Template [P , T ], * args : P .args , ** kwargs : P .kwargs
500+ ) -> T :
501+ # Bind the synthesis target to this Template call's signature for the
502+ # duration of the call, so `collect_tools` can build the tool from it.
503+ signature = template .__signature__
504+ with handler ({_synthesis_signature : lambda : signature }):
505+ return fwd ()
506+
507+ @implements (collect_tools )
508+ def _collect (
509+ self , env : collections .abc .Mapping [str , typing .Any ]
510+ ) -> collections .abc .Mapping [str , Tool ]:
511+ tools = dict (fwd ())
512+ signature = _synthesis_signature ()
513+ if signature is not None :
514+ final_tool = _synthesis_final_tool (signature , env )
515+ tools [final_tool .__name__ ] = final_tool
516+ return tools
517+
518+
361519@Operation .define
362520@functools .wraps (litellm .completion )
363521def completion (* args , ** kwargs ) -> typing .Any :
@@ -374,13 +532,16 @@ class _BoxedResponse[T](pydantic.BaseModel):
374532 value : T
375533
376534
535+ type AssistantResult [T ] = tuple [Message , typing .Sequence [DecodedToolCall ], T | None ]
536+
537+
377538@Operation .define
378539def call_assistant [T ](
379540 env : collections .abc .Mapping [str , typing .Any ],
380541 response_type : type [T ],
381542 model : str ,
382543 ** kwargs ,
383- ) -> MessageResult [T ]:
544+ ) -> AssistantResult [T ]:
384545 """Low-level LLM request. Handlers may log/modify requests and delegate via fwd().
385546
386547 This effect is emitted for model request/response rounds so handlers can
@@ -426,13 +587,29 @@ def call_assistant[T](
426587 raw_message = _make_message ({** message .model_dump (mode = "json" )})
427588 append_message (raw_message )
428589
590+ raw_tool_calls = message .get ("tool_calls" ) or []
429591 tool_calls : list [DecodedToolCall ] = []
430592 encoding : pydantic .TypeAdapter [DecodedToolCall ] = pydantic .TypeAdapter (
431593 Encodable [DecodedToolCall ]
432594 )
433- for raw_tool_call in message . get ( "tool_calls" ) or [] :
595+ for raw_tool_call in raw_tool_calls :
434596 try :
435597 tool_calls += [encoding .validate_python (raw_tool_call , context = tools )]
598+ if isinstance (tool_calls [- 1 ].tool , FinalTool ):
599+ if not (
600+ tool_calls [- 1 ].result_type == response_type
601+ or issubclass (tool_calls [- 1 ].result_type , response_type )
602+ ):
603+ raise TypeError (
604+ f"FinalTool '{ tool_calls [- 1 ].name } ' returns { tool_calls [- 1 ].result_type !r} , "
605+ f"which does not match the Template's result type { response_type !r} ."
606+ )
607+ if len (raw_tool_calls ) > 1 :
608+ raise TypeError (
609+ f"A FinalTool call must be the only tool call in its turn, but "
610+ f"{ len (raw_tool_calls )} tool calls were requested "
611+ f"({ sum (isinstance (encoding .validate_python (tc , context = tools ).tool , FinalTool ) for tc in raw_tool_calls )} of them final). Call the final tool alone."
612+ )
436613 except Exception as e :
437614 raise ToolCallDecodingError (
438615 raw_tool_call = raw_tool_call ,
@@ -460,12 +637,18 @@ def call_assistant[T](
460637 return (raw_message , tool_calls , result )
461638
462639
640+ type ToolResult [T ] = tuple [Message , T | None , bool ]
641+
642+
463643@Operation .define
464- def call_tool (tool_call : DecodedToolCall ) -> Message :
644+ def call_tool [ T ] (tool_call : DecodedToolCall [ T ] ) -> ToolResult [ T ] :
465645 """Implements a roundtrip call to a python function. Input is a json
466646 string representing an LLM tool call request parameters. The output is
467647 the serialised response to the model.
468648
649+ Returns the appended tool message, the tool's return value, and whether the
650+ call was a finalizing one (a :class:`FinalTool` call, whose value becomes the
651+ Template's result and terminates the completion loop).
469652 """
470653 # call tool with python types
471654 try :
@@ -485,7 +668,7 @@ def call_tool(tool_call: DecodedToolCall) -> Message:
485668 dict (role = "tool" , content = encoded_result , tool_call_id = tool_call .id ),
486669 )
487670 append_message (message )
488- return message
671+ return ( message , result , isinstance ( tool_call . tool , FinalTool ))
489672
490673
491674@Operation .define
@@ -616,7 +799,7 @@ def _call_assistant[T](
616799 response_type : type [T ],
617800 model : str ,
618801 ** kwargs ,
619- ) -> MessageResult [T ]:
802+ ) -> AssistantResult [T ]:
620803 _message_sequence = _get_history ().copy ()
621804
622805 with handler ({_get_history : lambda : _message_sequence }):
@@ -626,20 +809,24 @@ def _call_assistant[T](
626809 return (message , tool_calls , result )
627810
628811 @implements (call_tool )
629- def _call_tool (self , tool_call : DecodedToolCall ) -> Message :
812+ def _call_tool [ T ] (self , tool_call : DecodedToolCall [ T ] ) -> ToolResult [ T ] :
630813 """Handle tool execution with runtime error capture.
631814
632815 Runtime errors from tool execution are captured and returned as
633816 error messages to the LLM. Only exceptions matching `catch_tool_errors`
634817 are caught; others propagate up.
818+
819+ A captured failure is reported as ``is_final=False`` so that the
820+ completion loop continues even when a :class:`FinalTool` call raised:
821+ the model sees the error message and gets another turn to retry.
635822 """
636823 try :
637824 return fwd (tool_call )
638825 except ToolCallExecutionError as e :
639826 if isinstance (e .original_error , self .catch_tool_errors ):
640827 message = e .to_feedback_message (self .include_traceback )
641828 append_message (message )
642- return message
829+ return ( message , None , False )
643830 else :
644831 raise
645832
@@ -682,14 +869,17 @@ def _call[**P, T](
682869 message : Message = call_user (template .__prompt_template__ , env )
683870
684871 # loop based on: https://cookbook.openai.com/examples/reasoning_function_calls
685- tool_calls : list [DecodedToolCall ] = []
686872 result : T | None = None
687- while message ["role" ] != "assistant" or tool_calls :
873+ is_final : bool = False
874+ while not is_final :
688875 message , tool_calls , result = call_assistant (
689876 env , template .__signature__ .return_annotation , ** self .config
690877 )
691- for tool_call in tool_calls :
692- message = call_tool (tool_call )
878+ if tool_calls :
879+ for tool_call in tool_calls :
880+ message , result , is_final = call_tool (tool_call )
881+ else :
882+ is_final = True
693883
694884 try :
695885 _get_history ()
0 commit comments