fix(quickjs): normalize nested undefined tool args (#3935)

Closes #3926

---

In programmatic tool calling, a JS call like `await tools.myTool({ name:
undefined })` marshaled the property value through to Pydantic as the
QuickJS `Undefined` sentinel, raising a validation error instead of
letting the tool's schema default apply.

`_normalize_tool_input` already mapped a top-level `undefined`/`null`
payload to `{}`, but nested object properties were untouched. This
recursively omits dict keys whose value is `undefined` (so JS "absent"
semantics map to schema defaults) and converts `undefined` array entries
to `None` rather than dropping them, preserving array indices. Identity
comparison against the `UNDEFINED` singleton was also unreliable since
marshaling yields fresh `Undefined` instances, so detection now uses an
`isinstance` check.

Made by [Open SWE](https://openswe.vercel.app)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-22 22:15:17 -04:00
committed by GitHub
parent 09f1e6a8a0
commit 1b461a0d6c
2 changed files with 68 additions and 3 deletions
@@ -178,6 +178,39 @@ class _ConsoleBuffer:
return out, dropped
_UNDEFINED_TYPE = type(UNDEFINED)
def _is_undefined(value: Any) -> bool:
"""Return whether ``value`` is a QuickJS ``undefined`` marshal result.
Marshaling produces fresh ``Undefined`` instances rather than the
``UNDEFINED`` singleton, so identity comparison is unreliable.
"""
return isinstance(value, _UNDEFINED_TYPE)
def _strip_undefined(value: Any) -> Any:
"""Recursively drop ``undefined`` object properties so defaults apply.
JS ``undefined`` means "absent", so a dict key whose value is
``undefined`` is omitted, letting the tool's schema defaults take over.
Array entries are converted to ``None`` (JS ``null``) instead of being
dropped, since dropping would shift indices and corrupt the array.
"""
if isinstance(value, dict):
return {
key: _strip_undefined(val)
for key, val in value.items()
if not _is_undefined(val)
}
if isinstance(value, list):
return [
None if _is_undefined(item) else _strip_undefined(item) for item in value
]
return value
def _normalize_tool_input(raw: Any) -> dict[str, Any]:
"""Coerce whatever JS passed into `tools.X(...)` to a dict.
@@ -186,14 +219,14 @@ def _normalize_tool_input(raw: Any) -> dict[str, Any]:
`undefined`, a bare string, or a number (none of which a well-
formed tool call should produce, but the model is the model).
"""
if raw is None or raw is UNDEFINED:
if raw is None or _is_undefined(raw):
return {}
if isinstance(raw, dict):
return raw
return _strip_undefined(raw)
# Bare scalar / list — wrap under a conventional key so the tool's
# schema validation produces an informative error rather than a
# silent miss.
return {"input": raw}
return {"input": _strip_undefined(raw)}
def _synth_tool_call_id(tool_name: str) -> str:
@@ -364,6 +364,38 @@ async def test_tool_invocation_from_repl(repl: _ThreadREPL) -> None:
assert calls == [{"name": "world", "times": 2}]
async def test_undefined_object_property_uses_schema_default(
repl: _ThreadREPL,
) -> None:
"""A property passed as JS ``undefined`` is omitted so defaults apply."""
class _In(BaseModel):
name: str = Field(default="", description="Optional name")
def _fn(name: str = "") -> str:
return f"ok:{name!r}"
tool = StructuredTool.from_function(
name="myTool",
description="Echo the name with its repr.",
func=_fn,
args_schema=_In,
)
repl.install_tools([tool])
empty = await repl.eval_async("await tools.myTool({})")
assert empty.error_type is None, empty.error_message
assert empty.result == "ok:''"
explicit = await repl.eval_async('await tools.myTool({ name: "" })')
assert explicit.error_type is None, explicit.error_message
assert explicit.result == "ok:''"
undefined = await repl.eval_async("await tools.myTool({ name: undefined })")
assert undefined.error_type is None, undefined.error_message
assert undefined.result == "ok:''"
async def test_promise_all_runs_tools_concurrently(repl: _ThreadREPL) -> None:
"""`Promise.all` on two tool calls resolves both before returning."""
calls: list[dict] = []