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


def _define_lexical_reader(
Comment thread
eb8680 marked this conversation as resolved.
Outdated
env: collections.abc.Mapping[str, typing.Any], *, name: str
Comment thread
eb8680 marked this conversation as resolved.
Outdated
) -> "Tool[[], typing.Any]":
"""Construct a synthetic reader Tool for `env[name]`.

Raises if the value's `Encodable[nested_type(value)]` schema cannot
be generated. The caller is responsible for catching the failure
and deciding whether to skip the symbol.
"""
value = env[name]
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 env[name]

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 Tool.define(tool_fn)


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, plus synthetic
readers for other lexical symbols. Auto-captured from lexical context."""
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)
elif name.isidentifier():
try:
result[name] = _define_lexical_reader(env, name=name)
# `TypeError`/`AttributeError`/`NameError` absorb `nested_type`
# bugs on pathological values (MarkDecorator, method descriptors,
# Operations with unresolved forward-refs); see #673.
except (
pydantic.errors.PydanticSchemaGenerationError,
pydantic.errors.PydanticInvalidForJsonSchema,
pydantic.errors.PydanticUserError,
TypeError,
AttributeError,
NameError,
Comment thread
eb8680 marked this conversation as resolved.
Outdated
):
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 +283,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-Dl3k0fEKWeTJpZDy3rcqAKPKE46Cu",
"created": 1780108168,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_df8c8d3b43",
"choices": [
{
"finish_reason": "tool_calls",
"index": 0,
"message": {
"content": null,
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "_known_data"
},
"id": "call_DoVBVCnKiU5k7FTzWRo5nnzm",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 11,
"prompt_tokens": 6043,
"total_tokens": 6054,
"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": 0,
"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-Dl3k2MUxG3A1S3HrmgwMXFmDNScCt",
"created": 1780108170,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_df8c8d3b43",
"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_vypFiLTjH5MEpMKSL3VLoD4z",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 18,
"prompt_tokens": 6077,
"total_tokens": 6095,
"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": 6016,
"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-Dl3k5bfkN00lZh8L5D82Cr9YhOJsA",
"created": 1780108173,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_df8c8d3b43",
"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_EmO3tVtqZBqdL6K4dUqLmfgn",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 18,
"prompt_tokens": 6104,
"total_tokens": 6122,
"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": 6016,
"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-Dl3k7gKpQZx4an1oAXAhdYViG1NjR",
"created": 1780108175,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"system_fingerprint": "fp_df8c8d3b43",
"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_WhTflV4rMdUJUJOY2qqOHv7x",
"type": "function"
}
],
"function_call": null,
"provider_specific_fields": {
"refusal": null
},
"annotations": []
},
"provider_specific_fields": {}
}
],
"usage": {
"completion_tokens": 18,
"prompt_tokens": 6131,
"total_tokens": 6149,
"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": 6016,
"text_tokens": null,
"image_tokens": null,
"video_tokens": null
}
},
"service_tier": "default"
}
Loading
Loading