Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 1 addition & 5 deletions effectful/handlers/llm/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,7 @@ def flush_text() -> None:
@Operation.define
def call_system(template: Template) -> Message:
"""Get system instruction message(s) to prepend to all LLM prompts."""
system_prompt = textwrap.dedent(f"""
SYSTEM: You are a helpful LLM assistant named {template.__name__}.
""")

message = _make_message(dict(role="system", content=system_prompt))
message = _make_message(dict(role="system", content=template.__system_prompt__))
try:
history: collections.OrderedDict[str, Message] = _get_history()
if any(m["role"] == "system" for m in history.values()):
Expand Down
67 changes: 67 additions & 0 deletions effectful/handlers/llm/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,47 @@ def define(cls, *args, **kwargs) -> "Tool[P, T]":
return typing.cast("Tool[P, T]", super().define(*args, **kwargs))


def _build_context_tool[T](name: str, value: T) -> Tool[[], T]:
"""Create a synthetic read-only Tool for a lexical variable."""
from effectful.internals.unification import nested_type

def reader():
return value

reader.__name__ = name
reader.__doc__ = f"Read the value of lexical variable `{name}`"
reader.__annotations__ = {"return": nested_type(value).value}

return Tool.define(reader)


def _build_system_prompt(fn_or_cls) -> str:
"""Build a system prompt from a function or class's docstring and module source.

The docstring (if present directly on the object) forms the core of the prompt.
The module source code is appended as supporting context when available.
"""
parts: list[str] = []

doc = fn_or_cls.__doc__
if doc:
parts.append(inspect.cleandoc(doc))

try:
mod = inspect.getmodule(fn_or_cls)
assert mod is not None
source = inspect.getsource(mod)
parts.append(
"Here is the source code of the module where you are defined:\n\n" + source
)
except (TypeError, OSError):
mod = inspect.getmodule(fn_or_cls)
if mod is not None and mod.__doc__:
parts.append(inspect.cleandoc(mod.__doc__))

return "\n\n".join(parts)


class Template[**P, T](Tool[P, T]):
"""A :class:`Template` is a function that is implemented by a large language model.

Expand Down Expand Up @@ -168,6 +209,7 @@ class Template[**P, T](Tool[P, T]):
"""

__context__: ChainMap[str, Any]
__system_prompt__: str

@classmethod
def _validate_prompt(
Expand Down Expand Up @@ -229,6 +271,22 @@ def tools(self) -> Mapping[str, Tool]:
if isinstance(getattr(obj, attr_name), Tool):
result[attr_name] = getattr(obj, attr_name)

# Make tools for lexical variables
elif not (
not name.isidentifier()
or name.startswith("__")
or isinstance(obj, Operation)
or inspect.isclass(obj)
or inspect.isbuiltin(obj)
or inspect.ismodule(obj)
or inspect.isroutine(obj)
or inspect.isabstract(obj)
):
try:
result[name] = _build_context_tool(name, obj)
except Exception:
pass

# Deduplicate by tool identity and remove self-references.
#
# The same Tool can appear under multiple names when it is both
Expand Down Expand Up @@ -257,6 +315,7 @@ def __get__[S](self, instance: S | None, owner: type[S] | None = None):
if isinstance(instance, Agent):
assert isinstance(result, Template) and not hasattr(result, "__history__")
result.__history__ = instance.__history__ # type: ignore[attr-defined]
result.__system_prompt__ = instance.__system_prompt__ # type: ignore[attr-defined]
return result

@classmethod
Expand Down Expand Up @@ -318,6 +377,7 @@ def define[**Q, V](
)
op = super().define(default, *args, **kwargs)
op.__context__ = context # type: ignore[attr-defined]
op.__system_prompt__ = _build_system_prompt(_fn) # 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}`.
Expand Down Expand Up @@ -368,10 +428,17 @@ def send(self, user_input: str) -> str:
"""

__history__: OrderedDict[str, Mapping[str, Any]]
__system_prompt__: str

def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not hasattr(cls, "__history__"):
prop = functools.cached_property(lambda _: OrderedDict())
prop.__set_name__(cls, "__history__")
cls.__history__ = prop
if not hasattr(cls, "__system_prompt__"):
sp = functools.cached_property(
lambda self: _build_system_prompt(type(self))
)
sp.__set_name__(cls, "__system_prompt__")
cls.__system_prompt__ = sp # type: ignore[assignment]
6 changes: 3 additions & 3 deletions tests/test_handlers_llm_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ def f(self) -> int:
assert "random" in a.f.tools
# f is the template itself — found via self but correctly removed (non-recursive)
assert "f" not in a.f.tools
assert "local_variable" in a.f.__context__ and "local_variable" not in a.f.tools
assert "local_variable" in a.f.__context__ and "local_variable" in a.f.tools
assert a.f.tools["random"]() == 4

class B(A):
Expand All @@ -623,7 +623,7 @@ def reverse(self, s: str) -> str:
assert isinstance(b.f, Template)
assert "random" in b.f.tools
assert "reverse" in b.f.tools
assert "local_variable" in b.f.__context__ and "local_variable" not in a.f.tools
assert "local_variable" in b.f.__context__ and "local_variable" in b.f.tools


def test_template_method_nested_class():
Expand Down Expand Up @@ -654,7 +654,7 @@ def f(self) -> int:
assert "random" in a.f.tools
# f is the template itself — found via self but correctly removed (non-recursive)
assert "f" not in a.f.tools
assert "local_variable" in a.f.__context__ and "local_variable" not in a.f.tools
assert "local_variable" in a.f.__context__ and "local_variable" in a.f.tools
assert a.f.tools["random"]() == 4


Expand Down
Loading