From 008294bc561f04db92a6cbff02a0c8dc95ebe69c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:56:35 -0700 Subject: [PATCH 1/6] feat: skip self/cls parameters in function_tool schema generation When a class method decorated with @function_tool is used as a tool, the self/cls parameter was included in the generated JSON schema, causing OpenAI API 400 errors. This skips the first parameter when it has no type annotation and is named "self" or "cls". Also handles the case where RunContextWrapper/ToolContext follows self/cls as the second parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agents/function_schema.py | 13 +++++++- tests/test_function_schema.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index cff7f987e..611f9e2bc 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -287,6 +287,9 @@ def function_schema( takes_context = False filtered_params = [] + # Track whether the first real (non-self/cls) parameter has been processed for context check + self_or_cls_skipped = False + if params: first_name, first_param = params[0] # Prefer the evaluated type hint if available @@ -297,15 +300,23 @@ def function_schema( takes_context = True # Mark that the function takes context else: filtered_params.append((first_name, first_param)) + elif first_name in ("self", "cls"): + self_or_cls_skipped = True # Skip bound method receiver parameter else: filtered_params.append((first_name, first_param)) - # For parameters other than the first, raise error if any use RunContextWrapper or ToolContext. + # For parameters other than the first, raise error if any use RunContextWrapper or ToolContext + # (unless self/cls was skipped, in which case the second param is the effective first param). for name, param in params[1:]: ann = type_hints.get(name, param.annotation) if ann != inspect._empty: origin = get_origin(ann) or ann if origin is RunContextWrapper or origin is ToolContext: + if self_or_cls_skipped and not takes_context: + # self/cls was the first param, so this is the effective first param + takes_context = True + self_or_cls_skipped = False + continue raise UserError( f"RunContextWrapper/ToolContext param found at non-first position in function" f" {func.__name__}" diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 9771bda99..6ef819182 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -885,3 +885,61 @@ def func_with_annotated_multiple_field_constraints( with pytest.raises(ValidationError): # zero factor fs.params_pydantic_model(**{"score": 50, "factor": 0.0}) + + +def test_method_self_param_skipped(): + """Test that self parameter is skipped for class methods.""" + + class MyTools: + def greet(self, name: str) -> str: + return f"Hello, {name}" + + obj = MyTools() + fs = function_schema(obj.greet, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "self" not in props + assert "name" in props + assert fs.params_json_schema.get("required") == ["name"] + + +def test_classmethod_cls_param_skipped(): + """Test that cls parameter is skipped for classmethods passed as unbound.""" + + # Simulate a function whose first param is named cls with no annotation + code = compile("def greet(cls, name: str) -> str: ...", "", "exec") + ns: dict[str, Any] = {} + exec(code, ns) # noqa: S102 + fn = ns["greet"] + fn.__annotations__ = {"name": str, "return": str} + + fs = function_schema(fn, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "cls" not in props + assert "name" in props + + +def test_method_self_with_context_second_param(): + """Test that self is skipped and RunContextWrapper as second param is recognized.""" + + class MyTools: + def greet(self, ctx: RunContextWrapper[None], name: str) -> str: + return f"Hello, {name}" + + obj = MyTools() + fs = function_schema(obj.greet, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "self" not in props + assert "ctx" not in props + assert "name" in props + assert fs.takes_context is True + + +def test_regular_unannotated_first_param_still_included(): + """Test that a regular unannotated first param (not self/cls) is still included.""" + + def process(data, flag: bool = False) -> str: + return str(data) + + fs = function_schema(process, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "data" in props From 73b9a9f6775a23cc0541e44b5058705a51b533db Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:34:14 -0700 Subject: [PATCH 2/6] fix(schema): reject context param not immediately after self/cls The self_or_cls_skipped flag persisted across all remaining params, so `def f(self, x: int, ctx: RunContextWrapper)` incorrectly passed validation. Now only the param at index 1 (immediately after self/cls) is treated as the effective first param. Adds a test for this edge case. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agents/function_schema.py | 9 +++++---- tests/test_function_schema.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 611f9e2bc..4f2f8262f 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -306,14 +306,15 @@ def function_schema( filtered_params.append((first_name, first_param)) # For parameters other than the first, raise error if any use RunContextWrapper or ToolContext - # (unless self/cls was skipped, in which case the second param is the effective first param). - for name, param in params[1:]: + # (unless self/cls was skipped, in which case ONLY the param immediately after self/cls is + # treated as the effective first param). + for idx, (name, param) in enumerate(params[1:]): ann = type_hints.get(name, param.annotation) if ann != inspect._empty: origin = get_origin(ann) or ann if origin is RunContextWrapper or origin is ToolContext: - if self_or_cls_skipped and not takes_context: - # self/cls was the first param, so this is the effective first param + if self_or_cls_skipped and not takes_context and idx == 0: + # self/cls was the first param and this is immediately after it takes_context = True self_or_cls_skipped = False continue diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 6ef819182..77eab39c5 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -934,6 +934,18 @@ def greet(self, ctx: RunContextWrapper[None], name: str) -> str: assert fs.takes_context is True +def test_method_context_not_immediately_after_self_raises(): + """Test that RunContextWrapper at position 3+ (not immediately after self) raises UserError.""" + + class MyTools: + def greet(self, name: str, ctx: RunContextWrapper[None]) -> str: + return f"Hello, {name}" + + obj = MyTools() + with pytest.raises(UserError, match="non-first position"): + function_schema(obj.greet, use_docstring_info=False) + + def test_regular_unannotated_first_param_still_included(): """Test that a regular unannotated first param (not self/cls) is still included.""" From 2e855208862f3b212762dd441a7d74a4e7862e02 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:59:17 -0700 Subject: [PATCH 3/6] fix(schema): exclude self/cls from stored signature for to_call_args When self/cls was skipped during schema generation, the original signature (including self/cls) was stored in FuncSchema. This caused to_call_args to misalign parameter indices - skipping self instead of the context param. Now we rebuild the signature without self/cls so to_call_args iterates only the parameters the caller passes. --- src/agents/function_schema.py | 12 ++++++++++-- tests/test_function_schema.py | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 4f2f8262f..396b0e21a 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -421,14 +421,22 @@ def function_schema( if strict_json_schema: json_schema = ensure_strict_json_schema(json_schema) - # 5. Return as a FuncSchema dataclass + # 5. Build a signature that excludes self/cls so to_call_args iterates + # only the parameters the caller actually needs to pass. + if self_or_cls_skipped or (takes_context and params and params[0][0] in ("self", "cls")): + remaining = [p for name, p in params if name not in ("self", "cls")] + call_sig = sig.replace(parameters=remaining) + else: + call_sig = sig + + # 6. Return as a FuncSchema dataclass return FuncSchema( name=func_name, # Ensure description_override takes precedence even if docstring info is disabled. description=description_override or (doc_info.description if doc_info else None), params_pydantic_model=dynamic_model, params_json_schema=json_schema, - signature=sig, + signature=call_sig, takes_context=takes_context, strict_json_schema=strict_json_schema, ) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 77eab39c5..ccfe0d80b 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -946,6 +946,30 @@ def greet(self, name: str, ctx: RunContextWrapper[None]) -> str: function_schema(obj.greet, use_docstring_info=False) +def test_method_self_excluded_from_call_signature(): + """Test that self/cls is excluded from the stored signature used by to_call_args.""" + + # Simulate an unbound method with self as first param + code = compile( + "def greet(self, ctx, name: str) -> str: ...", "", "exec" + ) + ns: dict[str, Any] = {} + exec(code, ns) # noqa: S102 + fn = ns["greet"] + fn.__annotations__ = {"ctx": RunContextWrapper[None], "name": str, "return": str} + + fs = function_schema(fn, use_docstring_info=False) + # self should not be in the signature used by to_call_args + assert "self" not in fs.signature.parameters + assert "name" in fs.signature.parameters + assert fs.takes_context is True + + # to_call_args should produce only the non-context, non-self params + parsed = fs.params_pydantic_model(name="world") + args, kwargs = fs.to_call_args(parsed) + assert args == ["world"] + + def test_regular_unannotated_first_param_still_included(): """Test that a regular unannotated first param (not self/cls) is still included.""" From 22f1f83d08880626c91c9b398abe1c59eea9f229 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:45:01 -0700 Subject: [PATCH 4/6] fix: gate self/cls stripping on actual receiver skip Use a permanent receiver_was_skipped flag instead of checking params[0] name at call_sig construction time. Prevents stripping a self/cls parameter that is actually the context argument (e.g. def tool(self: RunContextWrapper[Any], q: str)). Co-Authored-By: Claude Opus 4.6 --- src/agents/function_schema.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 396b0e21a..7a03b707b 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -289,6 +289,10 @@ def function_schema( # Track whether the first real (non-self/cls) parameter has been processed for context check self_or_cls_skipped = False + # Permanent flag: True when an unannotated self/cls receiver was found and skipped. + # Unlike self_or_cls_skipped, this is never reset and is used to decide whether + # to strip self/cls from call_sig. + receiver_was_skipped = False if params: first_name, first_param = params[0] @@ -302,6 +306,7 @@ def function_schema( filtered_params.append((first_name, first_param)) elif first_name in ("self", "cls"): self_or_cls_skipped = True # Skip bound method receiver parameter + receiver_was_skipped = True else: filtered_params.append((first_name, first_param)) @@ -423,7 +428,7 @@ def function_schema( # 5. Build a signature that excludes self/cls so to_call_args iterates # only the parameters the caller actually needs to pass. - if self_or_cls_skipped or (takes_context and params and params[0][0] in ("self", "cls")): + if receiver_was_skipped: remaining = [p for name, p in params if name not in ("self", "cls")] call_sig = sig.replace(parameters=remaining) else: From 98ba075e1c8b8a86341ba7134ad1a21b38ea5ca5 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 21 Mar 2026 09:24:05 -0700 Subject: [PATCH 5/6] 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 --- src/agents/function_schema.py | 37 ++++++++++------------------------- tests/test_function_schema.py | 35 ++++++++++++--------------------- 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 7a03b707b..3dbfa5d8d 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -287,13 +287,6 @@ def function_schema( takes_context = False filtered_params = [] - # Track whether the first real (non-self/cls) parameter has been processed for context check - self_or_cls_skipped = False - # Permanent flag: True when an unannotated self/cls receiver was found and skipped. - # Unlike self_or_cls_skipped, this is never reset and is used to decide whether - # to strip self/cls from call_sig. - receiver_was_skipped = False - if params: first_name, first_param = params[0] # Prefer the evaluated type hint if available @@ -305,24 +298,22 @@ def function_schema( else: filtered_params.append((first_name, first_param)) elif first_name in ("self", "cls"): - self_or_cls_skipped = True # Skip bound method receiver parameter - receiver_was_skipped = True + # An unannotated self/cls means this is an unbound method or classmethod. + # Bound methods (e.g., instance.method) already have self stripped by Python. + raise UserError( + f"Function {func.__name__} has an unbound '{first_name}' parameter. " + f"Pass a bound method (e.g., instance.{func.__name__}) instead of the " + f"unbound class method." + ) else: filtered_params.append((first_name, first_param)) # For parameters other than the first, raise error if any use RunContextWrapper or ToolContext - # (unless self/cls was skipped, in which case ONLY the param immediately after self/cls is - # treated as the effective first param). - for idx, (name, param) in enumerate(params[1:]): + for name, param in params[1:]: ann = type_hints.get(name, param.annotation) if ann != inspect._empty: origin = get_origin(ann) or ann if origin is RunContextWrapper or origin is ToolContext: - if self_or_cls_skipped and not takes_context and idx == 0: - # self/cls was the first param and this is immediately after it - takes_context = True - self_or_cls_skipped = False - continue raise UserError( f"RunContextWrapper/ToolContext param found at non-first position in function" f" {func.__name__}" @@ -426,22 +417,14 @@ def function_schema( if strict_json_schema: json_schema = ensure_strict_json_schema(json_schema) - # 5. Build a signature that excludes self/cls so to_call_args iterates - # only the parameters the caller actually needs to pass. - if receiver_was_skipped: - remaining = [p for name, p in params if name not in ("self", "cls")] - call_sig = sig.replace(parameters=remaining) - else: - call_sig = sig - - # 6. Return as a FuncSchema dataclass + # 5. Return as a FuncSchema dataclass return FuncSchema( name=func_name, # Ensure description_override takes precedence even if docstring info is disabled. description=description_override or (doc_info.description if doc_info else None), params_pydantic_model=dynamic_model, params_json_schema=json_schema, - signature=call_sig, + signature=sig, takes_context=takes_context, strict_json_schema=strict_json_schema, ) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index ccfe0d80b..c6f8bcaaa 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -887,8 +887,8 @@ def func_with_annotated_multiple_field_constraints( fs.params_pydantic_model(**{"score": 50, "factor": 0.0}) -def test_method_self_param_skipped(): - """Test that self parameter is skipped for class methods.""" +def test_bound_method_self_not_in_schema(): + """Test that bound methods work normally (Python already strips self).""" class MyTools: def greet(self, name: str) -> str: @@ -902,8 +902,8 @@ def greet(self, name: str) -> str: assert fs.params_json_schema.get("required") == ["name"] -def test_classmethod_cls_param_skipped(): - """Test that cls parameter is skipped for classmethods passed as unbound.""" +def test_unbound_cls_param_raises(): + """Test that unbound classmethods with unannotated cls raise UserError.""" # Simulate a function whose first param is named cls with no annotation code = compile("def greet(cls, name: str) -> str: ...", "", "exec") @@ -912,14 +912,12 @@ def test_classmethod_cls_param_skipped(): fn = ns["greet"] fn.__annotations__ = {"name": str, "return": str} - fs = function_schema(fn, use_docstring_info=False) - props = fs.params_json_schema.get("properties", {}) - assert "cls" not in props - assert "name" in props + with pytest.raises(UserError, match="unbound 'cls' parameter"): + function_schema(fn, use_docstring_info=False) -def test_method_self_with_context_second_param(): - """Test that self is skipped and RunContextWrapper as second param is recognized.""" +def test_bound_method_with_context_second_param(): + """Test that bound methods with RunContextWrapper as second param work correctly.""" class MyTools: def greet(self, ctx: RunContextWrapper[None], name: str) -> str: @@ -928,6 +926,7 @@ def greet(self, ctx: RunContextWrapper[None], name: str) -> str: obj = MyTools() fs = function_schema(obj.greet, use_docstring_info=False) props = fs.params_json_schema.get("properties", {}) + # self is already stripped by Python for bound methods assert "self" not in props assert "ctx" not in props assert "name" in props @@ -946,8 +945,8 @@ def greet(self, name: str, ctx: RunContextWrapper[None]) -> str: function_schema(obj.greet, use_docstring_info=False) -def test_method_self_excluded_from_call_signature(): - """Test that self/cls is excluded from the stored signature used by to_call_args.""" +def test_unbound_method_with_self_raises(): + """Test that unbound methods with unannotated self raise UserError.""" # Simulate an unbound method with self as first param code = compile( @@ -958,16 +957,8 @@ def test_method_self_excluded_from_call_signature(): fn = ns["greet"] fn.__annotations__ = {"ctx": RunContextWrapper[None], "name": str, "return": str} - fs = function_schema(fn, use_docstring_info=False) - # self should not be in the signature used by to_call_args - assert "self" not in fs.signature.parameters - assert "name" in fs.signature.parameters - assert fs.takes_context is True - - # to_call_args should produce only the non-context, non-self params - parsed = fs.params_pydantic_model(name="world") - args, kwargs = fs.to_call_args(parsed) - assert args == ["world"] + with pytest.raises(UserError, match="unbound 'self' parameter"): + function_schema(fn, use_docstring_info=False) def test_regular_unannotated_first_param_still_included(): From f3f480cb07211c310bfaf7167b26362177dbe9c2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:02:05 -0700 Subject: [PATCH 6/6] fix: skip self/cls instead of rejecting to support decorator pattern The UserError for unannotated self/cls blocked @function_tool usage on class methods during decoration time. Now silently skips self/cls and stores a self_or_cls_skipped flag. The stored signature excludes self/cls so to_call_args produces correctly aligned arguments. Context params immediately after self/cls are recognized as effective-first position. --- src/agents/function_schema.py | 35 +++++++++++++------- tests/test_function_schema.py | 60 ++++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 3dbfa5d8d..3149f48cc 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -35,6 +35,8 @@ class FuncSchema: """The signature of the function.""" takes_context: bool = False """Whether the function takes a RunContextWrapper argument (must be the first argument).""" + self_or_cls_skipped: bool = False + """Whether an unannotated self/cls first parameter was skipped during schema generation.""" strict_json_schema: bool = True """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, as it increases the likelihood of correct JSON input.""" @@ -285,6 +287,7 @@ def function_schema( sig = inspect.signature(func) params = list(sig.parameters.items()) takes_context = False + self_or_cls_skipped = False filtered_params = [] if params: @@ -298,22 +301,25 @@ def function_schema( else: filtered_params.append((first_name, first_param)) elif first_name in ("self", "cls"): - # An unannotated self/cls means this is an unbound method or classmethod. - # Bound methods (e.g., instance.method) already have self stripped by Python. - raise UserError( - f"Function {func.__name__} has an unbound '{first_name}' parameter. " - f"Pass a bound method (e.g., instance.{func.__name__}) instead of the " - f"unbound class method." - ) + # Skip unannotated self/cls so @function_tool works on class methods. + # Bound methods already have self stripped by Python, so this handles + # the unbound case (decoration time). The caller must invoke with a + # bound method for correct runtime behavior. + self_or_cls_skipped = True else: filtered_params.append((first_name, first_param)) - # For parameters other than the first, raise error if any use RunContextWrapper or ToolContext - for name, param in params[1:]: + # For remaining parameters: if self/cls was skipped, the second param is effectively first + # and may be a context parameter. + remaining_params = params[1:] + for idx, (name, param) in enumerate(remaining_params): ann = type_hints.get(name, param.annotation) if ann != inspect._empty: origin = get_origin(ann) or ann if origin is RunContextWrapper or origin is ToolContext: + if self_or_cls_skipped and idx == 0: + takes_context = True + continue raise UserError( f"RunContextWrapper/ToolContext param found at non-first position in function" f" {func.__name__}" @@ -417,14 +423,21 @@ def function_schema( if strict_json_schema: json_schema = ensure_strict_json_schema(json_schema) - # 5. Return as a FuncSchema dataclass + # 5. Build stored signature excluding self/cls if it was skipped + stored_sig = sig + if self_or_cls_skipped: + new_params = [p for p in sig.parameters.values() if p.name not in ("self", "cls")] + stored_sig = sig.replace(parameters=new_params) + + # 6. Return as a FuncSchema dataclass return FuncSchema( name=func_name, # Ensure description_override takes precedence even if docstring info is disabled. description=description_override or (doc_info.description if doc_info else None), params_pydantic_model=dynamic_model, params_json_schema=json_schema, - signature=sig, + signature=stored_sig, takes_context=takes_context, + self_or_cls_skipped=self_or_cls_skipped, strict_json_schema=strict_json_schema, ) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index c6f8bcaaa..17e6e2c85 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -902,8 +902,8 @@ def greet(self, name: str) -> str: assert fs.params_json_schema.get("required") == ["name"] -def test_unbound_cls_param_raises(): - """Test that unbound classmethods with unannotated cls raise UserError.""" +def test_unbound_cls_param_skipped(): + """Test that unbound classmethods with unannotated cls have cls skipped.""" # Simulate a function whose first param is named cls with no annotation code = compile("def greet(cls, name: str) -> str: ...", "", "exec") @@ -912,8 +912,12 @@ def test_unbound_cls_param_raises(): fn = ns["greet"] fn.__annotations__ = {"name": str, "return": str} - with pytest.raises(UserError, match="unbound 'cls' parameter"): - function_schema(fn, use_docstring_info=False) + fs = function_schema(fn, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "cls" not in props + assert "name" in props + assert fs.self_or_cls_skipped is True + assert "cls" not in fs.signature.parameters def test_bound_method_with_context_second_param(): @@ -945,8 +949,8 @@ def greet(self, name: str, ctx: RunContextWrapper[None]) -> str: function_schema(obj.greet, use_docstring_info=False) -def test_unbound_method_with_self_raises(): - """Test that unbound methods with unannotated self raise UserError.""" +def test_unbound_method_self_skipped_with_context(): + """Test that unbound methods with self+context have self skipped and context recognized.""" # Simulate an unbound method with self as first param code = compile( @@ -957,8 +961,48 @@ def test_unbound_method_with_self_raises(): fn = ns["greet"] fn.__annotations__ = {"ctx": RunContextWrapper[None], "name": str, "return": str} - with pytest.raises(UserError, match="unbound 'self' parameter"): - function_schema(fn, use_docstring_info=False) + fs = function_schema(fn, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "self" not in props + assert "ctx" not in props + assert "name" in props + assert fs.self_or_cls_skipped is True + assert fs.takes_context is True + assert "self" not in fs.signature.parameters + + +def test_unbound_method_to_call_args_alignment(): + """Test that to_call_args produces correct args when self was skipped.""" + + code = compile("def greet(self, name: str, count: int = 1) -> str: ...", "", "exec") + ns: dict[str, Any] = {} + exec(code, ns) # noqa: S102 + fn = ns["greet"] + fn.__annotations__ = {"name": str, "count": int, "return": str} + + fs = function_schema(fn, use_docstring_info=False) + assert fs.self_or_cls_skipped is True + + parsed = fs.params_pydantic_model(name="world", count=3) + args, kwargs = fs.to_call_args(parsed) + assert args == ["world", 3] + assert kwargs == {} + + +def test_decorator_pattern_does_not_raise(): + """Test that function_schema works on unbound methods (decorator pattern).""" + + # This simulates @function_tool applied at class definition time + class MyTools: + def search(self, query: str) -> str: + return query + + # At decoration time, MyTools.search is unbound + fs = function_schema(MyTools.search, use_docstring_info=False) + props = fs.params_json_schema.get("properties", {}) + assert "self" not in props + assert "query" in props + assert fs.self_or_cls_skipped is True def test_regular_unannotated_first_param_still_included():