Skip to content

Commit 0eaa6ef

Browse files
authored
Implements Polymorphic Templates (#504)
* implemented polymorphic templates switch to using types of dynamic values instead of parameters * fixed tests
1 parent e12d4a1 commit 0eaa6ef

7 files changed

Lines changed: 206 additions & 26 deletions

docs/source/agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ def wrapper(self, *args, **kwargs):
4141

4242
def _format_model_input(self, template, other, *args, **kwargs):
4343
# update prompt with previous list of messages
44-
prompt = fwd()
44+
prompt, enc_ty = fwd()
4545
if Agent.current_agent() is self:
4646
assert self is other
4747
self.state.extend(prompt)
4848
prompt = self.state
49-
return prompt
49+
return (prompt, enc_ty)
5050

5151
def _compute_response(self, *args, **kwargs):
5252
# save response into persisted state

effectful/handlers/llm/completions.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
from litellm.types.utils import ModelResponse
2222

2323
from effectful.handlers.llm import Template, Tool
24-
from effectful.handlers.llm.encoding import type_to_encodable_type
24+
from effectful.handlers.llm.encoding import (
25+
Encodable,
26+
type_to_encodable_type,
27+
)
28+
from effectful.internals.unification import TypeExpression, nested_type
2529
from effectful.ops.semantics import fwd, handler
2630
from effectful.ops.syntax import ObjectInterpretation, defop, implements
2731
from effectful.ops.types import Operation
@@ -213,16 +217,18 @@ def call_with_json_args(
213217

214218

215219
@defop
216-
def compute_response(template: Template, model_input: list[Any]) -> ModelResponse:
220+
def compute_response[**P, T](
221+
template: Template[P, T], model_input_pair: tuple[list[Any], Encodable[T]]
222+
) -> ModelResponse:
217223
"""Produce a complete model response for an input message sequence. This may
218224
involve multiple API requests if tools are invoked by the model.
219225
220226
"""
221-
ret_type = template.__signature__.return_annotation
227+
model_input, ret_type_encoder = model_input_pair
222228
tools = template.tools
223229

224230
tool_schemas = [function_definition(t) for t in tools.values()]
225-
response_encoding_type: type | None = type_to_encodable_type(ret_type).t
231+
response_encoding_type: type | None = ret_type_encoder.t
226232
if response_encoding_type == str:
227233
response_encoding_type = None
228234

@@ -262,7 +268,12 @@ def compute_response(template: Template, model_input: list[Any]) -> ModelRespons
262268
)
263269

264270

265-
def decode_response[**P, T](template: Callable[P, T], response: ModelResponse) -> T:
271+
type ModelInput[T] = tuple[list[Any], Encodable[T]]
272+
273+
274+
def decode_response[**P, T](
275+
template: Callable[P, T], model_input_pair: ModelInput[T], response: ModelResponse
276+
) -> T:
266277
"""Decode an LLM response into an instance of the template return type. This
267278
operation should raise if the output cannot be decoded.
268279
"""
@@ -273,8 +284,7 @@ def decode_response[**P, T](template: Callable[P, T], response: ModelResponse) -
273284
result_str = last_resp.content or last_resp.reasoning_content
274285
assert result_str
275286

276-
ret_type = template.__signature__.return_annotation
277-
encodable_ty = type_to_encodable_type(ret_type)
287+
encodable_ty = model_input_pair[1]
278288

279289
if encodable_ty.t == str:
280290
# if encoding as a type, value is just directly what the llm returned
@@ -291,28 +301,29 @@ def decode_response[**P, T](template: Callable[P, T], response: ModelResponse) -
291301
@defop
292302
def format_model_input[**P, T](
293303
template: Template[P, T], *args: P.args, **kwargs: P.kwargs
294-
) -> list[Any]:
304+
) -> ModelInput[T]:
295305
"""Format a template applied to arguments into a sequence of input
296306
messages.
297307
298308
"""
299-
bound_args = template.__signature__.bind(*args, **kwargs)
309+
signature = template.__signature__
310+
311+
bound_args = signature.bind(*args, **kwargs)
300312
bound_args.apply_defaults()
301313
# encode arguments
302314
arguments = {}
303-
for param in bound_args.arguments:
304-
encoder = type_to_encodable_type(
305-
template.__signature__.parameters[param].annotation
306-
)
307-
encoded = encoder.encode(bound_args.arguments[param], template.__context__)
315+
for param, value in bound_args.arguments.items():
316+
ty: TypeExpression = nested_type(value).value
317+
encoder = type_to_encodable_type(ty) # type: ignore
318+
encoded = encoder.encode(value, template.__context__)
308319
arguments[param] = encoder.serialize(encoded)
309320

310321
prompt = _OpenAIPromptFormatter().format_as_messages(
311322
template.__prompt_template__, **arguments
312323
)
313324

314325
# install prefix if the return type has a return annotation
315-
ret_type = template.__signature__.return_annotation
326+
ret_type = template.__type_rule__(*args, **kwargs)
316327
origin = typing.get_origin(ret_type)
317328
ret_type = ret_type if origin is None else origin
318329
ret_type_encoder = type_to_encodable_type(ret_type)
@@ -327,7 +338,7 @@ def format_model_input[**P, T](
327338
# Note: The OpenAI api only seems to accept images in the 'user' role. The
328339
# effect of different roles on the model's response is currently unclear.
329340
messages = [{"type": "message", "content": prompt, "role": "user"}]
330-
return messages
341+
return (messages, ret_type_encoder)
331342

332343

333344
class InstructionHandler(ObjectInterpretation):
@@ -347,12 +358,16 @@ def __init__(self, instruction: str):
347358
self.instruction = instruction
348359

349360
@implements(format_model_input)
350-
def _inject_instruction(self, template: Template, *args, **kwargs) -> list[Any]:
361+
def _inject_instruction(
362+
self, template: Template, *args, **kwargs
363+
) -> tuple[list[Any], Any]:
351364
"""Append instruction message to the formatted model input."""
352-
messages = fwd()
353-
return messages + [
354-
{"type": "message", "content": self.instruction, "role": "user"}
355-
]
365+
(messages, decode_ty) = fwd()
366+
return (
367+
messages
368+
+ [{"type": "message", "content": self.instruction, "role": "user"}],
369+
decode_ty,
370+
)
356371

357372

358373
class RetryLLMHandler(ObjectInterpretation):
@@ -421,4 +436,4 @@ def _call[**P, T](
421436
) -> T:
422437
model_input = format_model_input(template, *args, **kwargs)
423438
resp = compute_response(template, model_input)
424-
return decode_response(template, resp)
439+
return decode_response(template, model_input, resp)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"choices": [
3+
{
4+
"finish_reason": "stop",
5+
"index": 0,
6+
"message": {
7+
"annotations": [],
8+
"content": "{\"value\":3}",
9+
"function_call": null,
10+
"provider_specific_fields": {
11+
"refusal": null
12+
},
13+
"role": "assistant",
14+
"tool_calls": null
15+
},
16+
"provider_specific_fields": {}
17+
}
18+
],
19+
"created": 1769197760,
20+
"id": "chatcmpl-D1HRgEJGEJucGvOOBa4PSeUiXRhDs",
21+
"model": "gpt-4o-2024-08-06",
22+
"object": "chat.completion",
23+
"service_tier": "default",
24+
"system_fingerprint": "fp_cbf1785567",
25+
"usage": {
26+
"completion_tokens": 9,
27+
"completion_tokens_details": {
28+
"accepted_prediction_tokens": 0,
29+
"audio_tokens": 0,
30+
"image_tokens": null,
31+
"reasoning_tokens": 0,
32+
"rejected_prediction_tokens": 0,
33+
"text_tokens": null
34+
},
35+
"prompt_tokens": 378,
36+
"prompt_tokens_details": {
37+
"audio_tokens": 0,
38+
"cached_tokens": 0,
39+
"image_tokens": null,
40+
"text_tokens": null
41+
},
42+
"total_tokens": 387
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"choices": [
3+
{
4+
"finish_reason": "stop",
5+
"index": 0,
6+
"message": {
7+
"annotations": [],
8+
"content": "Here is another instance of one of the items in the list: \"banana\".",
9+
"function_call": null,
10+
"provider_specific_fields": {
11+
"refusal": null
12+
},
13+
"role": "assistant",
14+
"tool_calls": null
15+
},
16+
"provider_specific_fields": {}
17+
}
18+
],
19+
"created": 1769197761,
20+
"id": "chatcmpl-D1HRhDz8ooFuBQH2N51NmO4zn0g8r",
21+
"model": "gpt-4o-2024-08-06",
22+
"object": "chat.completion",
23+
"service_tier": "default",
24+
"system_fingerprint": "fp_deacdd5f6f",
25+
"usage": {
26+
"completion_tokens": 17,
27+
"completion_tokens_details": {
28+
"accepted_prediction_tokens": 0,
29+
"audio_tokens": 0,
30+
"image_tokens": null,
31+
"reasoning_tokens": 0,
32+
"rejected_prediction_tokens": 0,
33+
"text_tokens": null
34+
},
35+
"prompt_tokens": 271,
36+
"prompt_tokens_details": {
37+
"audio_tokens": 0,
38+
"cached_tokens": 0,
39+
"image_tokens": null,
40+
"text_tokens": null
41+
},
42+
"total_tokens": 288
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"choices": [
3+
{
4+
"finish_reason": "stop",
5+
"index": 0,
6+
"message": {
7+
"annotations": [],
8+
"content": "{\"value\":{\"title\":\"Brave New World\",\"author\":\"Aldous Huxley\"}}",
9+
"function_call": null,
10+
"provider_specific_fields": {
11+
"refusal": null
12+
},
13+
"role": "assistant",
14+
"tool_calls": null
15+
},
16+
"provider_specific_fields": {}
17+
}
18+
],
19+
"created": 1769197762,
20+
"id": "chatcmpl-D1HRi9XFRJF8gmevEAZ7xh0hxjgki",
21+
"model": "gpt-4o-2024-08-06",
22+
"object": "chat.completion",
23+
"service_tier": "default",
24+
"system_fingerprint": "fp_cbf1785567",
25+
"usage": {
26+
"completion_tokens": 23,
27+
"completion_tokens_details": {
28+
"accepted_prediction_tokens": 0,
29+
"audio_tokens": 0,
30+
"image_tokens": null,
31+
"reasoning_tokens": 0,
32+
"rejected_prediction_tokens": 0,
33+
"text_tokens": null
34+
},
35+
"prompt_tokens": 373,
36+
"prompt_tokens_details": {
37+
"audio_tokens": 0,
38+
"cached_tokens": 0,
39+
"image_tokens": null,
40+
"text_tokens": null
41+
},
42+
"total_tokens": 396
43+
}
44+
}

tests/test_handlers_llm_provider.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import json
99
import logging
1010
import os
11-
from collections.abc import Callable
11+
from collections.abc import Callable, Sequence
1212
from enum import Enum
1313
from pathlib import Path
1414

@@ -346,6 +346,39 @@ def review_book(plot: str) -> BookReview:
346346
raise NotImplementedError
347347

348348

349+
@Template.define
350+
def generate_similar[T](examples: Sequence[T]) -> T:
351+
"Generate another instance of one of the items in {examples}"
352+
raise NotHandled
353+
354+
355+
@dataclass
356+
class Book:
357+
title: str
358+
author: str
359+
360+
361+
@requires_openai
362+
@pytest.mark.parametrize(
363+
"model_input",
364+
[
365+
[1, 2, 3, 4],
366+
["apple", "orange", "pear"],
367+
[
368+
Book("1984", "George Orwell"),
369+
Book("To Kill a Mockingbird", "Harper Lee"),
370+
Book("The Great Gatsby", "F. Scott Fitzgerald"),
371+
],
372+
],
373+
)
374+
def test_polymorphic_templates(request, model_input):
375+
with (
376+
handler(ReplayLiteLLMProvider(request, model_name="gpt-4o")),
377+
handler(LimitLLMCallsHandler(max_calls=5)),
378+
):
379+
assert isinstance(generate_similar(model_input), type(model_input[0]))
380+
381+
349382
class TestPydanticBaseModelReturn:
350383
@requires_openai
351384
def test_pydantic_basemodel_return(self, request):

tests/test_handlers_llm_template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ class TemplateStringIntp(ObjectInterpretation):
155155
def _[**P, T](
156156
self, template: Template[P, T], *args: P.args, **kwargs: P.kwargs
157157
) -> T:
158-
model_input = format_model_input(template, *args, **kwargs)
158+
model_input, _ = format_model_input(template, *args, **kwargs)
159159
template_result = model_input[0]["content"]
160160
assert len(template_result) == 1
161161
return template_result[0]["text"]

0 commit comments

Comments
 (0)