Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
103 changes: 92 additions & 11 deletions effectful/handlers/llm/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,29 +178,110 @@ def to_feedback_message(self, include_traceback: bool) -> Message:
type MessageResult[T] = tuple[Message, typing.Sequence[DecodedToolCall], T | None]


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 expose_lexical_readers() -> bool:
Comment thread
eb8680 marked this conversation as resolved.
Outdated
"""Effect controlling whether `_collect_tools` builds synthetic
read-only Tools for non-Tool/Template values in a Template's
lexical scope.

Default behaviour is *off*: only real Tools/Templates/Agents reach
the LLM, and the lexical context is invisible. Install
`LexicalReaders` to flip it on for the call-site where the LLM
should be able to inspect closure state.
"""
return False


class LexicalReaders(ObjectInterpretation):
"""Handler that enables synthetic lexical-reader generation in
`_collect_tools`. Each plain value in a Template's lexical context
becomes a zero-argument Tool that returns the captured value.
"""

@implements(expose_lexical_readers)
def _enabled(self) -> bool:
return True


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 = {}
"""Operations and Templates available as tools. When
`expose_lexical_readers` is on (see :class:`LexicalReaders`),
plain values in the lexical context are also wrapped as synthetic
read-only tools."""
result: dict[str, Tool] = {}
readers_on = expose_lexical_readers()

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)
elif readers_on and name.isidentifier():
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

# 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 Down Expand Up @@ -243,7 +324,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
8 changes: 4 additions & 4 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."""
"""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