Skip to content

Commit c3555c4

Browse files
committed
Synthesis-based template handling
1 parent 72bb624 commit c3555c4

4 files changed

Lines changed: 513 additions & 22 deletions

File tree

effectful/handlers/llm/completions.py

Lines changed: 204 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from effectful.handlers.llm.evaluation import ReplSession
3434
from 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-
182180
class _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)
363521
def 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
378539
def 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()

effectful/handlers/llm/encoding.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ class DecodedToolCall[T]:
108108
id: ToolCallID
109109
name: str
110110

111+
@property
112+
def result_type(self) -> type[T]:
113+
return inspect.signature(self.tool).return_annotation
114+
111115

112116
if typing.TYPE_CHECKING:
113117
type Encodable[T] = typing.Annotated[T, "encoded"]

effectful/handlers/llm/template.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,37 @@ def define(cls, *args, **kwargs) -> "Tool[P, T]":
109109
return typing.cast("Tool[P, T]", super().define(*args, **kwargs))
110110

111111

112+
class FinalTool[**P, T](Tool[P, T]):
113+
"""A :class:`Tool` whose invocation *finalizes* a :class:`Template` call.
114+
115+
During completion a :class:`Template` lets the LLM freely call any tool in
116+
scope, feeding each tool's result back for another turn. When the LLM
117+
instead calls a :class:`FinalTool`, that tool's return value becomes the
118+
Template's result and the completion loop terminates -- no further model
119+
turn is taken, so the value is attributed to executing the tool rather than
120+
generated by the model.
121+
122+
This is the mechanism behind code-synthesis completion (see
123+
:class:`effectful.handlers.llm.completions.SynthesizeAndCall`): the LLM
124+
"answers" by calling a final tool with a function it wrote, the harness
125+
applies that function to the original inputs, and the resulting value is the
126+
answer.
127+
128+
A finalizing call that *fails* does not terminate the loop -- the error is
129+
fed back as a tool message and the model is given another turn (see
130+
:class:`effectful.handlers.llm.completions.RetryLLMHandler`).
131+
"""
132+
133+
@classmethod
134+
def define(cls, *args, **kwargs) -> "FinalTool[P, T]":
135+
"""Define a final tool.
136+
137+
See :func:`effectful.ops.types.Operation.define` for more information on
138+
the use of :func:`FinalTool.define`.
139+
"""
140+
return typing.cast("FinalTool[P, T]", super().define(*args, **kwargs))
141+
142+
112143
class Template[**P, T](Tool[P, T]):
113144
"""A :class:`Template` is a function that is implemented by a large language model.
114145

0 commit comments

Comments
 (0)