Agent Rules / Dispatch and reflection
Why does my agent break calls that go through **kwargs?
Rule AR-11
Avoid **kwargs/...args pass-through layers that hide the real signature; spell the parameters or use ParamSpec/Parameters<>.
A function that takes (*args, **kwargs) and hands them on says nothing about what the callee accepts. Add a parameter to the bottom function and the layers above still look correct, and a caller three layers up can pass a misspelled keyword that nothing checks until the bottom call runs. Spell the parameters at the layers people call, or give the wrapper a type that carries the callee's signature.
Why it happens
Python. **kwargs collects keyword arguments into a dict, and *args collects positionals into a tuple. Inside the function they are plain values, so inner(*args, **kwargs) forwards whatever arrived. The wrapper's own signature, which is what inspect.signature and a type checker report, is (*args, **kwargs). With the usual annotation Any, nothing is checked at the wrapper's call sites.
Python's typing gives three fixes. Unpack[TypedDict] types **kwargs by key and value (PEP 692, Python 3.12; the docs example is def foo(**kwargs: Unpack[Movie]), which expects name: str and year: int) and has no runtime effect. ParamSpec captures a callable's whole parameter list: P.args annotates *args, P.kwargs annotates **kwargs, and Callable[P, T] reuses the list (PEP 612, Python 3.10). Concatenate adds or removes leading parameters. PEP 612 states the problem directly: with Callable[..., R], an ellipsis in place of parameter types means no validation on arguments, so a wrong call type-checks and fails at runtime.
TypeScript and JavaScript. (...args: any[]) => any is the same hole. TypeScript can carry the signature instead. The TypeScript 3.0 release notes describe generic rest parameters constrained to an array type, which capture the parameter list as a tuple and let you spread it back, as in bind returning (...args: U) => V. The Parameters<Type> utility builds a tuple type from a function type's parameters (for overloads, the last signature), and ReturnType<Type> does the same for the result.
Bad and good
A service layer that adds audit logging around a billing call.
# bad: three layers forward everything
def create_invoice(customer_id: str, lines: list[Line], currency: str = "EUR", due_days: int = 30): ...
def billing_call(**kwargs):
return audit(create_invoice, **kwargs)
def audit(fn, *args, **kwargs):
log.info(f"calling {fn.__name__}")
return fn(*args, **kwargs)
billing_call(customer_id="c1", lines=lines, curency="USD") # typo reaches runtime// bad: the wrapper accepts anything
function createInvoice(customerId: string, lines: Line[], currency = "EUR", dueDays = 30): Invoice { /* ... */ }
function billingCall(...args: any[]) {
return audit(createInvoice, ...args);
}
function audit(fn: (...a: any[]) => any, ...args: any[]) {
log.info(`calling ${fn.name}`);
return fn(...args);
}# good: the signature survives the wrapper
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def audit(fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
log.info(f"calling {fn.__name__}")
return fn(*args, **kwargs)
def billing_call(customer_id: str, lines: list[Line], currency: str = "EUR") -> Invoice:
return audit(create_invoice, customer_id, lines, currency)// good: the signature survives the wrapper
function audit<A extends unknown[], R>(fn: (...args: A) => R, ...args: A): R {
log.info(`calling ${fn.name}`);
return fn(...args);
}
function billingCall(...args: Parameters<typeof createInvoice>): Invoice {
return audit(createInvoice, ...args);
}In the good versions a misspelled keyword or a wrong argument count is reported at the call to billing_call or billingCall, and renaming a parameter of createInvoice shows up as errors at each caller. The billing_call wrapper spells its three parameters, which is also the right thing when the wrapper is a public entry point.
Trade-offs
- Pass-through is legitimate for thin adapters. A wrapper whose job is to forward everything, such as a logging layer, a retry layer or a subclass override that adds one argument, gains little from spelling every parameter. Use
ParamSpecorParameters<>there so it stays generic and checked. - Spelling parameters costs edits. Add an option to the bottom function and each layer that spells it needs the same edit. That is also the point where the agent is told about it.
- Public entry points should spell parameters. A caller reading the signature or the generated docs gets the real contract. Reserve pass-through for internal glue.
- **
TypedDictwithUnpacksuits option bags.** When a function takes many keyword options, a namedTypedDictdocuments them once and the checker validates keys.typing.Unpackwas added in Python 3.11 (https://docs.python.org/3/library/typing.html#typing.Unpack); usingUnpack[TypedDict]to type**kwargscomes from PEP 692, which targets Python 3.12 (https://peps.python.org/pep-0692/). Older versions useUnpackfromtyping_extensions, as the typing docs show. - **
Parameters<>takes the last overload.** For overloaded functions the utility reports only the last signature, so a wrapper typed that way loses the others. - A dataclass or options object (AR-16) is often the better design than a long forwarded keyword list.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | ANN401 any-type | arguments and returns annotated Any (the option lint.flake8-annotations.allow-star-arg-any exists; the settings page describes it as "Whether to suppress ANN401 for dynamically typed *args and **kwargs arguments", default false: https://docs.astral.sh/ruff/settings/) | 2026-10-30 |
| pyright | reportMissingParameterType, reportUnknownParameterType, reportUnknownArgumentType (error in strict) | *args/**kwargs without annotations, and calls passing unknown-typed values | 2026-10-30 |
| typescript-eslint | no-explicit-any (in recommended; option ignoreRestArgs default false) | (...args: any[]) rest parameters, including Array<any> and readonly any[] | 2026-10-30 |
| typescript-eslint | no-unsafe-argument (in recommended-type-checked, needs type information) | spreading an any[] into a call | 2026-10-30 |
| tsconfig | noImplicitAny (on with strict) | parameters with no annotation that would be inferred any | 2026-10-30 |
Rule block for AGENTS.md / CLAUDE.md
- Public functions list their parameters. Don't take `**kwargs` / `...args: any[]` and forward them.
- A wrapper that must forward everything types the forwarding:
Python `Callable[P, R]` with `*args: P.args, **kwargs: P.kwargs`; TypeScript `<A extends unknown[], R>(fn: (...args: A) => R, ...args: A)` or `Parameters<typeof fn>`.
- For keyword option bags use a TypedDict with `Unpack`, or a dataclass.
- Before adding a parameter, list every layer between the entry point and the function, and update each one.Edge cases
- Subclass overrides that take
**kwargsand callsuper().method(**kwargs)have the same blindness. Spell the new parameter and the ones you use. - Decorators are the most common pass-through layer. The decorator-specific rules, including
functools.wraps, are in AR-12. - **
functools.partialandbind** build forwarding layers at runtime. The TypeScriptbindexample in the TypeScript 3.0 notes types the remaining parameters as a tuple. - **
**kwargsinto adictfor storage** (config, metadata) is not forwarding. The dict is data, and AR-16 covers its shape. - Positional-only and keyword-only parameters (the
/and*markers) are part of the real signature. A forwarding layer that flattens them changes which calls are valid.
What LLMGRAPH shows
A function passed to audit(fn, *args, **kwargs) prints no calls to it found in this repo and passed as a value in 1 place: calls through it are not visible.
References
- Python typing:
ParamSpec,Concatenate,UnpackwithTypedDict: https://docs.python.org/3/library/typing.html - PEP 612 (Parameter Specification Variables, final, Python 3.10): https://peps.python.org/pep-0612/
- PEP 692 (
Unpackfor**kwargstyping, final, Python 3.12): https://peps.python.org/pep-0692/ - Ruff ANN401: https://docs.astral.sh/ruff/rules/any-type/
- Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- TypeScript 3.0 release notes (generic rest parameters): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html
- TypeScript utility types (
Parameters,ReturnType): https://www.typescriptlang.org/docs/handbook/utility-types.html - TSConfig reference: https://www.typescriptlang.org/tsconfig/
- typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-argument/
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: verify items resolved (docs check)
Related
- AR-12 Do decorators hide function signatures from coding agents?
- AR-13 Why is Any a blind spot for coding agents?
- AR-16 Why do dict-shaped objects make agents guess?
- AR-25 What to check before changing a function signature?
Last verified