fix: Agent clone and serialization with a tools-less chat generator - #12365
Conversation
An Agent built on a chat generator whose run() has no `tools` parameter is a supported configuration, but it could not be cloned or round-tripped. `__init__` normalizes `tools` to `[]`, and both `clone()` and `to_dict()` read that normalized value back into the constructor. The tool-support guard used `tools is not None`, so the empty list was treated as "tools were provided" and raised TypeError on the second pass. The equivalent guard in `run()` already uses truthiness and treats an empty list as no tools; this makes `__init__` agree with it. Only a list is measured, so a Toolset is still never tested for truthiness at init - the reason the `is not None` form was used in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@lets-order-some-fries is attempting to deploy a commit to the deepset Team on Vercel. A member of the Team first needs to authorize it. |
|
Hi @lets-order-some-fries, thanks a lot for your contribution! 🙏 We noticed that the Contributor License Agreement (CLA) check ( To get your PR reviewed, please sign the CLA via the link in the |
|
Thanks for signing the CLA, @lets-order-some-fries! 🎉 This PR is now ready for review again and the reviewer has been re-assigned. |
|
Gentle nudge on this one, with the current state so it's quick to action:
No rush from my side, and I realise the queue is long. Happy to rebase onto current |
Related Issues
Agent's init/serialization paths. Happy to open one if you'd prefer the bug tracked separately.Proposed Changes:
An
Agentbuilt on a chat generator whoserun()has notoolsparameter is a supported configuration — the suite already covers constructing and running one inTestAgentRun::test_no_tools_with_chat_generator_without_tools_support. But such anAgentcannot be cloned or serialized: both raiseTypeError.The cause is a value that changes shape between the two passes:
__init__guards withtools is not None(agent.py:441).tools=Nonepasses.__init__then normalizes it away:self.tools = tools if tools is not None else [](agent.py:469).clone()rebuilds fromgetattr(self, name)for each init param, so it passestools=[].to_dict()emitsserialize_tools_or_toolset(self.tools)→[], whichfrom_dicthands back to__init__.[] is not NoneisTrue, so the guard fires and raises — for an Agent that has no tools.The same rule is already implemented a second time for the run path, at
agent.py:734:That one uses truthiness, so it correctly treats an empty list as "no tools". The two guards disagree, and the
run()one is right — an empty list carries no tools. This change makes__init__agree with it.The
is not Noneform was deliberate, per the comment it replaces: truthiness on aToolsetcalls__len__, which forSearchableToolsetwould iterate and prematurely warm it up at init. That property is preserved — only alistis ever measured:isinstance(tools, list)short-circuits before any__len__call, so aToolsetis still never measured here. A non-emptytoolsvalue still raises exactly as before, and passingtools=[]explicitly is now accepted for the same reasontools=Noneis.How did you test it?
Three unit tests, one per affected entry point, added to the classes that already own those behaviours. Each fails on
mainand passes with the change — verified by reverting onlyagent.pyand re-running:Full runs at this head (
36706ae):hatch run test:unit test/components/agents/test_agent.py→ 104 passedhatch run test:unit test/components/agents/ test/tools/ test/core/→ 2359 passed, 173 deselectedhatch run test:types→ Success: no issues found in 432 source fileshatch run fmt→ All checks passed!pre-commit run --files <the three changed files>→ all hooks Passedtest_chat_generator_must_support_tools(non-empty tools + tools-less generator still raises) is unchanged and still passes.Notes for the reviewer
One pre-existing failure, unrelated to this PR, that I did not tick as green. A full
hatch run test:unitat this head reports 2 failed, 6116 passed. Both failures are intest/components/generators/chat/test_openai.py:They reproduce identically on a clean checkout of
main(915888bb) with none of my changes applied, so they are not caused by this PR. For what it's worth, they look like fallout fromchore: unpin openai(#12348): withopenai>=2.6.0now resolving to openai 3.1.0, the usage payload gained fields the fixtures don't expect — the diff is confined tometa, e.g.prompt_tokens_detailsnow carryingcache_write_tokens/image_tokens/text_tokens. Flagging it because a fresh install currently hits it; happy to open a separate issue or PR if that's useful.One test-scaffolding change.
MockChatGeneratorWithoutTools.to_dict()returned{"type": "MockChatGeneratorWithoutTools", "data": {}}— a bare class name and adatakey, neither of which round-trips throughcomponent_from_dict. It is now a resolvable dotted path withinit_parameters, which is what the serialization test needs. Nothing referenced the old shape.I did not touch the
:raises TypeError:docstring, since it remains accurate — a generator that doesn't support tools still raises when tools are actually provided.Alternatives I considered and rejected: leaving
self.toolsun-normalized (wide blast radius — plenty of code assumes a list), or emittingNonefromclone()/to_dict()when empty (fixes the round trip but leaves an explicittools=[]still raising, and leaves the two guards disagreeing).AI assistance disclosure: this PR was written with an AI assistant. I reviewed the change, and ran the tests and quality checks reported above.
Checklist
fix:.