豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 98ba075

Browse files
committed
fix: reject unbound methods instead of silently breaking at runtime
Codex review found that skipping self/cls from schema generation makes unbound methods appear supported, but execution fails with TypeError because the receiver is never injected. Bound methods (instance.method) already work - Python strips self before inspect.signature sees it. - Raise UserError for unbound self/cls instead of silent schema-only skip - Remove dead receiver_was_skipped logic and call_sig stripping (P2 fix) - Clean up self_or_cls_skipped variable (now unreachable) - Update tests to expect UserError for unbound methods
1 parent 22f1f83 commit 98ba075

File tree

2 files changed

+23
-49
lines changed

2 files changed

+23
-49
lines changed

src/agents/function_schema.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -287,13 +287,6 @@ def function_schema(
287287
takes_context = False
288288
filtered_params = []
289289

290-
# Track whether the first real (non-self/cls) parameter has been processed for context check
291-
self_or_cls_skipped = False
292-
# Permanent flag: True when an unannotated self/cls receiver was found and skipped.
293-
# Unlike self_or_cls_skipped, this is never reset and is used to decide whether
294-
# to strip self/cls from call_sig.
295-
receiver_was_skipped = False
296-
297290
if params:
298291
first_name, first_param = params[0]
299292
# Prefer the evaluated type hint if available
@@ -305,24 +298,22 @@ def function_schema(
305298
else:
306299
filtered_params.append((first_name, first_param))
307300
elif first_name in ("self", "cls"):
308-
self_or_cls_skipped = True # Skip bound method receiver parameter
309-
receiver_was_skipped = True
301+
# An unannotated self/cls means this is an unbound method or classmethod.
302+
# Bound methods (e.g., instance.method) already have self stripped by Python.
303+
raise UserError(
304+
f"Function {func.__name__} has an unbound '{first_name}' parameter. "
305+
f"Pass a bound method (e.g., instance.{func.__name__}) instead of the "
306+
f"unbound class method."
307+
)
310308
else:
311309
filtered_params.append((first_name, first_param))
312310

313311
# For parameters other than the first, raise error if any use RunContextWrapper or ToolContext
314-
# (unless self/cls was skipped, in which case ONLY the param immediately after self/cls is
315-
# treated as the effective first param).
316-
for idx, (name, param) in enumerate(params[1:]):
312+
for name, param in params[1:]:
317313
ann = type_hints.get(name, param.annotation)
318314
if ann != inspect._empty:
319315
origin = get_origin(ann) or ann
320316
if origin is RunContextWrapper or origin is ToolContext:
321-
if self_or_cls_skipped and not takes_context and idx == 0:
322-
# self/cls was the first param and this is immediately after it
323-
takes_context = True
324-
self_or_cls_skipped = False
325-
continue
326317
raise UserError(
327318
f"RunContextWrapper/ToolContext param found at non-first position in function"
328319
f" {func.__name__}"
@@ -426,22 +417,14 @@ def function_schema(
426417
if strict_json_schema:
427418
json_schema = ensure_strict_json_schema(json_schema)
428419

429-
# 5. Build a signature that excludes self/cls so to_call_args iterates
430-
# only the parameters the caller actually needs to pass.
431-
if receiver_was_skipped:
432-
remaining = [p for name, p in params if name not in ("self", "cls")]
433-
call_sig = sig.replace(parameters=remaining)
434-
else:
435-
call_sig = sig
436-
437-
# 6. Return as a FuncSchema dataclass
420+
# 5. Return as a FuncSchema dataclass
438421
return FuncSchema(
439422
name=func_name,
440423
# Ensure description_override takes precedence even if docstring info is disabled.
441424
description=description_override or (doc_info.description if doc_info else None),
442425
params_pydantic_model=dynamic_model,
443426
params_json_schema=json_schema,
444-
signature=call_sig,
427+
signature=sig,
445428
takes_context=takes_context,
446429
strict_json_schema=strict_json_schema,
447430
)

tests/test_function_schema.py

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -887,8 +887,8 @@ def func_with_annotated_multiple_field_constraints(
887887
fs.params_pydantic_model(**{"score": 50, "factor": 0.0})
888888

889889

890-
def test_method_self_param_skipped():
891-
"""Test that self parameter is skipped for class methods."""
890+
def test_bound_method_self_not_in_schema():
891+
"""Test that bound methods work normally (Python already strips self)."""
892892

893893
class MyTools:
894894
def greet(self, name: str) -> str:
@@ -902,8 +902,8 @@ def greet(self, name: str) -> str:
902902
assert fs.params_json_schema.get("required") == ["name"]
903903

904904

905-
def test_classmethod_cls_param_skipped():
906-
"""Test that cls parameter is skipped for classmethods passed as unbound."""
905+
def test_unbound_cls_param_raises():
906+
"""Test that unbound classmethods with unannotated cls raise UserError."""
907907

908908
# Simulate a function whose first param is named cls with no annotation
909909
code = compile("def greet(cls, name: str) -> str: ...", "<test>", "exec")
@@ -912,14 +912,12 @@ def test_classmethod_cls_param_skipped():
912912
fn = ns["greet"]
913913
fn.__annotations__ = {"name": str, "return": str}
914914

915-
fs = function_schema(fn, use_docstring_info=False)
916-
props = fs.params_json_schema.get("properties", {})
917-
assert "cls" not in props
918-
assert "name" in props
915+
with pytest.raises(UserError, match="unbound 'cls' parameter"):
916+
function_schema(fn, use_docstring_info=False)
919917

920918

921-
def test_method_self_with_context_second_param():
922-
"""Test that self is skipped and RunContextWrapper as second param is recognized."""
919+
def test_bound_method_with_context_second_param():
920+
"""Test that bound methods with RunContextWrapper as second param work correctly."""
923921

924922
class MyTools:
925923
def greet(self, ctx: RunContextWrapper[None], name: str) -> str:
@@ -928,6 +926,7 @@ def greet(self, ctx: RunContextWrapper[None], name: str) -> str:
928926
obj = MyTools()
929927
fs = function_schema(obj.greet, use_docstring_info=False)
930928
props = fs.params_json_schema.get("properties", {})
929+
# self is already stripped by Python for bound methods
931930
assert "self" not in props
932931
assert "ctx" not in props
933932
assert "name" in props
@@ -946,8 +945,8 @@ def greet(self, name: str, ctx: RunContextWrapper[None]) -> str:
946945
function_schema(obj.greet, use_docstring_info=False)
947946

948947

949-
def test_method_self_excluded_from_call_signature():
950-
"""Test that self/cls is excluded from the stored signature used by to_call_args."""
948+
def test_unbound_method_with_self_raises():
949+
"""Test that unbound methods with unannotated self raise UserError."""
951950

952951
# Simulate an unbound method with self as first param
953952
code = compile(
@@ -958,16 +957,8 @@ def test_method_self_excluded_from_call_signature():
958957
fn = ns["greet"]
959958
fn.__annotations__ = {"ctx": RunContextWrapper[None], "name": str, "return": str}
960959

961-
fs = function_schema(fn, use_docstring_info=False)
962-
# self should not be in the signature used by to_call_args
963-
assert "self" not in fs.signature.parameters
964-
assert "name" in fs.signature.parameters
965-
assert fs.takes_context is True
966-
967-
# to_call_args should produce only the non-context, non-self params
968-
parsed = fs.params_pydantic_model(name="world")
969-
args, kwargs = fs.to_call_args(parsed)
970-
assert args == ["world"]
960+
with pytest.raises(UserError, match="unbound 'self' parameter"):
961+
function_schema(fn, use_docstring_info=False)
971962

972963

973964
def test_regular_unannotated_first_param_still_included():

0 commit comments

Comments
 (0)