Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
116 changes: 115 additions & 1 deletion effectful/handlers/llm/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import functools
import inspect
import json
import pydoc
import string
import textwrap
import traceback
import types
import typing
import uuid

Expand Down Expand Up @@ -209,6 +211,113 @@ def _collect_tools(
return result


def _build_definition_reader(
Comment thread
eb8680 marked this conversation as resolved.
Outdated
value: typing.Any, name: str, env: collections.abc.Mapping[str, typing.Any]
) -> Tool | None:
"""Build a Tool that returns the source / help() output of a class or
function. Probe is `inspect.getsource` reachability; symbols whose
source is unreachable (builtin C, REPL lambdas, etc.) are skipped."""
try:
inspect.getsource(value)
except (OSError, TypeError):
return None

kind = "class" if inspect.isclass(value) else "function"

def body(level: typing.Literal["short", "full"] = "short") -> str:
Comment thread
eb8680 marked this conversation as resolved.
Outdated
obj = env[name]
if level == "short":
return pydoc.render_doc(obj, renderer=pydoc.plaintext) # type: ignore[attr-defined]
return inspect.getsource(obj)

body.__name__ = name
body.__doc__ = (
f"Read the definition of `{name}` (a {kind}). "
f'`level="short"` (default) returns `help({name})` output; '
f'`level="full"` returns `inspect.getsource({name})`. '
f"Calls must include `level` explicitly under OpenAI strict mode."
)
body.__annotations__ = {
"level": typing.Literal["short", "full"],
"return": str,
}
return Tool.define(body)


@functools.singledispatch
def _build_synthetic_reader(
Comment thread
eb8680 marked this conversation as resolved.
Outdated
value: typing.Any,
name: str,
env: collections.abc.Mapping[str, typing.Any],
) -> Tool | None:
"""Build a synthetic read-only Tool for a lexical symbol.

Default branch: value-reader. Probe is the §2c Encodable schema check;
on failure (unencodable value), return None and let the symbol be
skipped silently.

Registrations route classes and functions through the
definition-reader path (`_build_definition_reader`), and route Tool,
Agent, and module values to a no-op (already collected by
`_collect_tools` or intentionally excluded).
"""
try:
inferred: typing.Any = nested_type(value).value
Comment thread
eb8680 marked this conversation as resolved.
Outdated
adapter: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter(
Encodable[inferred]
)
adapter.json_schema()
except Exception:
# The probe chains through several third-party libraries
# (nested_type, inspect.signature, typing.get_overloads,
# Pydantic schema generation). Any failure means "this symbol
Comment thread
eb8680 marked this conversation as resolved.
Outdated
# cannot be exposed as a synthetic reader", so we catch broadly.
# The cost of a too-narrow catch is a crash mid-call_assistant;
# the cost of a too-broad catch is silently skipping a symbol
# that might have worked.
return None

def body():
return env[name]

body.__name__ = name
body.__doc__ = f"Read the value of lexical variable `{name}` (type `{inferred}`)."
Comment thread
eb8680 marked this conversation as resolved.
Outdated
body.__annotations__ = {"return": inferred}
return Tool.define(body)


_build_synthetic_reader.register(type, _build_definition_reader)
Comment thread
eb8680 marked this conversation as resolved.
Outdated
_build_synthetic_reader.register(types.FunctionType, _build_definition_reader)
Comment thread
eb8680 marked this conversation as resolved.
Outdated
_build_synthetic_reader.register(types.BuiltinFunctionType, _build_definition_reader)
Comment thread
eb8680 marked this conversation as resolved.
Outdated
_build_synthetic_reader.register(types.MethodType, _build_definition_reader)
Comment thread
eb8680 marked this conversation as resolved.
Outdated


@_build_synthetic_reader.register(types.ModuleType)
Comment thread
eb8680 marked this conversation as resolved.
Outdated
@_build_synthetic_reader.register(Tool)
Comment thread
eb8680 marked this conversation as resolved.
Outdated
@_build_synthetic_reader.register(Agent)
Comment thread
eb8680 marked this conversation as resolved.
Outdated
def _no_synthetic_reader(value, name, env):
return None


def _collect_synthetic_readers(
env: collections.abc.Mapping[str, typing.Any],
already_collected: collections.abc.Set[str],
) -> collections.abc.Mapping[str, Tool]:
"""Synthetic readers for lexical symbols not already covered by
`_collect_tools`. Skips dunder names and any name already in
`already_collected`."""
result: dict[str, Tool] = {}
for name, obj in env.items():
if name in already_collected:
continue
if name.startswith("__"):
continue
tool = _build_synthetic_reader(obj, name, env)
if tool is not None:
result[name] = tool
return result


@Operation.define
@functools.wraps(litellm.completion)
def completion(*args, **kwargs) -> typing.Any:
Expand Down Expand Up @@ -243,7 +352,12 @@ 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))
# Add synthetic readers for non-Tool lexical symbols. These mirror the
# bound-args layer that LiteLLMProvider._call adds to env, so the LLM
# sees readers for the Template's arguments alongside other lexical
# context.
tools.update(_collect_synthetic_readers(env, set(tools)))
Comment thread
eb8680 marked this conversation as resolved.
Outdated
tool_specs = {
k: typing.cast(
pydantic.TypeAdapter[typing.Any],
Expand Down
27 changes: 22 additions & 5 deletions effectful/handlers/llm/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@

from effectful.ops.types import Annotation, Operation

_LEXICAL_READERS_PREFACE = (
Comment thread
eb8680 marked this conversation as resolved.
Outdated
"You also have access to read-only tools for inspecting the lexical "
"scope where this template is defined. Their names match variable "
"names from that scope; calling one returns the current value."
)


class _IsRecursiveAnnotation(Annotation):
"""
Expand Down Expand Up @@ -215,15 +221,19 @@ 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_synthetic_readers,
_collect_tools,
)

result = _collect_tools(self.__context__)
result = dict(_collect_tools(self.__context__))
result.update(_collect_synthetic_readers(self.__context__, set(result)))

# 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 +322,14 @@ 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__ = "\n\n".join( # type: ignore[attr-defined]
part
for part in (
inspect.getdoc(mod) if mod is not None else None,
_LEXICAL_READERS_PREFACE,
)
if part
)
# 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"
}
Loading
Loading