Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9457bce
Add synthetic readers for lexical context (closes #497)
datvo06 May 30, 2026
76d86a6
Fix mypy types in synthetic-reader probe
datvo06 May 30, 2026
368e360
Constrain test_tool_calling prompt against reader exploration
datvo06 May 30, 2026
704be6f
Replace synthetic-reader singledispatch with _LexicalVariableTool
datvo06 Jun 7, 2026
b3e4b6f
Skip class values at _LexicalVariableTool.define
datvo06 Jun 7, 2026
6ef4728
Collapse skip mechanism into one _is_synthetic_reader_eligible predicate
datvo06 Jun 7, 2026
7f313cb
Pure-Encodable reader collection: drop isinstance pre-filter
datvo06 Jun 7, 2026
b4809ab
Black-format the test parametrize call
datvo06 Jun 7, 2026
5b569ab
Drop dunder filter from synthetic-reader gate; link Test A to #674
datvo06 Jun 8, 2026
4cc807e
Merge remote-tracking branch 'origin/master' into dn-pr545-synthetic-…
datvo06 Jun 8, 2026
b69af3b
Post-#675/#676 fixup: subclass + snapshot semantics, record Test A
datvo06 Jun 8, 2026
324689e
stronger test
datvo06 Jun 8, 2026
8226eca
Identity-check returned values in lexical-reader exposure test
datvo06 Jun 8, 2026
d44cc6c
Drop docstring-substring test; remove now-unused type-ignore
datvo06 Jun 8, 2026
4631e12
Gate synthetic lexical-reader generation behind LexicalReaders handler
datvo06 Jun 8, 2026
bbe3c33
remove weak test
datvo06 Jun 8, 2026
ea01308
Format
datvo06 Jun 8, 2026
74c1ae2
Promote collect_tools to a public Operation; LexicalReaders overrides it
datvo06 Jun 9, 2026
c19d6f9
Drop redundant Tool|Template|Agent isinstance check in LexicalReaders
datvo06 Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 92 additions & 12 deletions effectful/handlers/llm/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,29 +178,75 @@ def to_feedback_message(self, include_traceback: bool) -> Message:
type MessageResult[T] = tuple[Message, typing.Sequence[DecodedToolCall], T | None]


def _collect_tools(
class _LexicalVariableTool[T](Tool[[], T]):
"""A zero-arg `Tool` that returns the captured value of a variable
from a `Template`'s lexical context.

Tools are constructed fresh each `call_assistant` invocation, so
the reader closes over the snapshot `value` rather than the
surrounding `env` — in-place mutation of a mutable value is still
visible (same object reference), but rebinding the source name is
not.
"""

@classmethod
def define(cls, value: typing.Any, *, name: str) -> "Tool[[], typing.Any]":
"""Construct a synthetic reader Tool that returns `value`.

Raises if `Encodable[nested_type(value)]` cannot be generated.
The caller is responsible for catching the failure and deciding
whether to skip the symbol.
"""
assert not isinstance(value, Tool), (
"Tools are real tools and must not be re-wrapped as lexical readers."
)
typ: typing.Any = nested_type(value).value
# Probe schema generation; raises if `Encodable[typ]` is not implemented.
pydantic.TypeAdapter(Encodable[typ]).json_schema()

def tool_fn():
return value

tool_fn.__name__ = name
tool_fn.__qualname__ = name
tool_fn.__module__ = type(value).__module__
tool_fn.__doc__ = (
f"Reads the value of lexical variable `{name}` from the "
f"enclosing scope where this Template was defined. Takes "
f"no arguments; returns the current value."
)
tool_fn.__annotations__ = {"return": typ}
return super().define(tool_fn)


@Operation.define
def collect_tools(
env: collections.abc.Mapping[str, typing.Any],
) -> collections.abc.Mapping[str, Tool]:
"""Operations and Templates available as tools. Auto-capture from lexical context."""
result = {}
"""Return the tools available to a Template given its lexical context.

Default rule: real `Tool` and `Template` values bound directly in
`env`, plus `Tool` methods discovered through the MRO of any
`Agent` instance in `env`. Same-Tool-under-different-names is
deduped so each Tool appears exactly once.

Handlers (see :class:`LexicalReaders`) may override this to add
synthetic readers, hide tools, etc.
"""
result: dict[str, Tool] = {}

for name, obj in env.items():
# Collect tools directly in context
if isinstance(obj, Tool | Template):
result[name] = obj

# Collect tools as methods on Agent instances in context
elif isinstance(obj, Agent):
for cls in type(obj).__mro__:
for attr_name in vars(cls):
if isinstance(getattr(obj, attr_name), Tool):
result[f"{name}__{attr_name}"] = getattr(obj, attr_name)

# The same Tool can appear under multiple names when it is both
# visible in the enclosing scope *and* discovered via an Agent
# instance's MRO. Since Tools are hashable Operations and
# instance-method Tools are cached per instance, we keep only
# the last name for each unique tool object.
# Same Tool can appear under multiple names when visible both in the
# enclosing scope and via an Agent instance's MRO. Keep only the
# last name for each unique tool object.
tool2name = {tool: name for name, tool in sorted(result.items())}
for name, tool in tuple(result.items()):
if tool2name[tool] != name:
Expand All @@ -209,6 +255,40 @@ def _collect_tools(
return result


class LexicalReaders(ObjectInterpretation):
"""Override `collect_tools` to also expose plain values from the
lexical context as zero-argument read-only Tools. Each non-Tool,
non-Template, non-Agent value bound to a valid identifier is
wrapped via `_LexicalVariableTool` if `Encodable[T]` accepts it;
schema-generation failures cause the symbol to be skipped.
"""

@implements(collect_tools)
def _collect(
self, env: collections.abc.Mapping[str, typing.Any]
) -> collections.abc.Mapping[str, Tool]:
result = dict(fwd())
for name, obj in env.items():
if name in result or not name.isidentifier():
continue
try:
result[name] = _LexicalVariableTool.define(obj, name=name)
# `TypeError` joins the three Pydantic errors because the
# `Encodable[T]` registry raises `TypeError` to signal
# "no schema possible" — e.g. `_pydantic_type_operation`,
# `_pydantic_type_term`, and `_pydantic_callable`'s
# incomplete-signature path. Same intent as the Pydantic
# cases, different exception class.
except (
pydantic.errors.PydanticSchemaGenerationError,
pydantic.errors.PydanticInvalidForJsonSchema,
pydantic.errors.PydanticUserError,
TypeError,
):
continue
return result


@Operation.define
@functools.wraps(litellm.completion)
def completion(*args, **kwargs) -> typing.Any:
Expand Down Expand Up @@ -243,7 +323,7 @@ def call_assistant[T](
ResultDecodingError: If the result cannot be decoded. The error
includes the raw assistant message for retry handling.
"""
tools = _collect_tools(env)
tools = dict(collect_tools(env))
tool_specs = {
k: typing.cast(
pydantic.TypeAdapter[typing.Any],
Expand Down
10 changes: 5 additions & 5 deletions effectful/handlers/llm/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,15 @@ def __prompt_template__(self) -> str:

@property
def tools(self) -> Mapping[str, Tool]:
"""Operations and Templates available as tools. Auto-capture from lexical context."""
from effectful.handlers.llm.completions import _collect_tools
"""Operations and Templates available as tools, plus synthetic
readers for other lexical symbols. Auto-captured from lexical context."""
from effectful.handlers.llm.completions import collect_tools

result = _collect_tools(self.__context__)
result = dict(collect_tools(self.__context__))

# We remove the template itself from the tool map unless it is explicitly
# marked as recursive (see test_template_method, test_template_method_nested_class).
if not _is_recursive_signature(self.__signature__):
result = dict(result) # copy to allow mutation
for name, tool in tuple(result.items()):
if tool is self:
del result[name]
Expand Down Expand Up @@ -312,7 +312,7 @@ def define[**Q, V](
op = super().define(default, *args, **kwargs)
op.__context__ = context # type: ignore[attr-defined]
mod = inspect.getmodule(_fn)
op.__system_prompt__ = inspect.getdoc(mod) if mod is not None else "" # type: ignore[attr-defined]
op.__system_prompt__ = inspect.getdoc(mod) or "" # type: ignore[attr-defined]
# Keep validation on original define-time callables, but skip the bound wrapper path.
# to avoid dropping `self` from the signature and falsely rejecting valid prompt fields like `{self.name}`.
is_bound_wrapper = (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"id": "chatcmpl-DoaWCNbxu6WP3Lx3l3Dmkmr5CBxwg",
"created": 1780949148,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_6c2953e649",
"choices": [
{
"finish_reason": "tool_calls",
"index": 0,
"message": {
"content": null,
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "_known_data"
},
"id": "call_nEkf11zJbFZRFcBclPCAvTao",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 11,
"prompt_tokens": 3753,
"total_tokens": 3764,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
},
"prompt_tokens_details": {
"audio_tokens": 0,
"cached_tokens": 2816,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
}
},
"service_tier": "default"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"id": "chatcmpl-DoaWFqScxo1oUrffPxQIyJYv8ENNr",
"created": 1780949151,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_6c2953e649",
"choices": [
{
"finish_reason": "tool_calls",
"index": 0,
"message": {
"content": null,
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"a\":10,\"b\":20}",
"name": "add_numbers"
},
"id": "call_TABbctvXDmYyjourZxIgLFo1",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 18,
"prompt_tokens": 3787,
"total_tokens": 3805,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
},
"prompt_tokens_details": {
"audio_tokens": 0,
"cached_tokens": 3712,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
}
},
"service_tier": "default"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"id": "chatcmpl-DoaWHTP5RK0QyZdDhiHbaUNkv3IfS",
"created": 1780949153,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_6c2953e649",
"choices": [
{
"finish_reason": "tool_calls",
"index": 0,
"message": {
"content": null,
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"a\":30,\"b\":30}",
"name": "add_numbers"
},
"id": "call_SpMwdgqjznHPUWgE19dxgSnc",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 18,
"prompt_tokens": 3814,
"total_tokens": 3832,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
},
"prompt_tokens_details": {
"audio_tokens": 0,
"cached_tokens": 3712,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
}
},
"service_tier": "default"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"id": "chatcmpl-DoaWJQAi5ihtJlJL7algRwDhzcgcx",
"created": 1780949155,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_6c2953e649",
"choices": [
{
"finish_reason": "tool_calls",
"index": 0,
"message": {
"content": null,
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"a\":60,\"b\":40}",
"name": "add_numbers"
},
"id": "call_IZ66N2KVq8wyYt3mAOIqfxW0",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 18,
"prompt_tokens": 3841,
"total_tokens": 3859,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
},
"prompt_tokens_details": {
"audio_tokens": 0,
"cached_tokens": 3712,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
}
},
"service_tier": "default"
}
Loading
Loading