mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user