[Bug] Two routers share one Conversation across threads, so every caller gets every task's messages (#2054) - #2068
Conversation
…rsation instead of sharing one across threads (kyegomez#2054)] MultiAgentRouter and HybridHierarchicalClusterSwarm both built one Conversation in __init__ and wrote to it from run()/route_task(). Both also fan those same methods across a thread pool - hybrid_hiearchical_peer_swarm.py:212 via run_concurrently(self.run, tasks), multi_agent_router.py:485 via executor.submit(self.route_task, task) - so N threads appended to one object and each caller got every task's messages back, not its own. Demonstrated with the same shape in isolation, 8 concurrent callers: shared instance -> [1, 2, 3, 4, 5, 6, 7, 8] per-call -> [1, 1, 1, 1, 1, 1, 1, 1] Resetting the shared Conversation at the top of run() would fix sequential reuse but not this: concurrent callers would still race on the reset. The conversation is per-task state, so it is now constructed in the per-task entry point and threaded explicitly into the helpers that record into it. That also removes the sequential leak, since nothing survives the call. self.conversation is gone from both classes rather than left vestigial; no caller in the repo read it, and leaving it would be a shared object that looks live but is never written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The diagnosis here is right and the approach is the correct one — threading a per-task 1. Conflicts with master
Resolution is to take both sides: keep your Everything else merged cleanly. I verified the resolved file still has all of #2087's fixes intact, none of which are on your branch: And your change is fully applied on top — Please resolve by merging 2. The signature change breaks the tests #2087 addedThis is the part that needs work beyond the conflict. On the resolved merge: Five regressions, all from router.handle_single_handoff(
{"handoffs": [{"agent_name": "A1", "task": None}]}, ""
)They now need the conversation passed in. They are the tests covering 3. On the missing testYour note says a concurrency assertion would not run in CI because the suite needs live credentials. That is true of the old tests in that file, but #2087 added a router, _ = _local_router()
results = router.concurrent_batch_run(["a", "b", "c"])
# each result sees only its own task's messagesruns offline. Given this PR fixes a data race, a test that would have caught it is worth having. Also worth a second look
Note also that #2076 is still open and edits |
…sation-in-threaded-routers # Conflicts: # swarms/structs/multi_agent_router.py
|
All four done. Merged, not rebased. 1. ConflictMerge commit Re-ran your verification on the resolved file: 2. The five regressionsFixed. The tests called the handlers with the old two-argument signature; they now pass a Same 22 (the live-credential ones), and the extra pass is the new test below. No regressions. 3. The offline test — and one thing I had to redoAdded First version was weak and I caught it: I had removed Restored that line, so the helper still satisfies master's code path. The test now fails on master for the actual reason: Three concurrent tasks, all interleaved into one history, and that whole thing returned as the result for 4.
|
Part of #2054 — the two structures that are a data race. The nine sequential-reuse leaks in that issue are a separate concern and are not in this diff.
What is wrong
MultiAgentRouterandHybridHierarchicalClusterSwarmeach build oneConversationin__init__and write to it from the per-task path. Both also fan that same path across a thread pool:hybrid_hiearchical_peer_swarm.py:212—run_concurrently(self.run, tasks, ...)multi_agent_router.py:485—executor.submit(self.route_task, task)So N threads append to one object, and each returns
history_output_formatter(...)over it. Every caller receives all tasks' messages rather than its own.Evidence
The same shape in isolation, 8 concurrent callers, printing how many messages each one sees:
The shared column is the current behaviour: caller 8 sees the other seven tasks.
Why not just reset it in run()
Resetting the shared
Conversationat the top ofrun()fixes sequential reuse, which is the other half of #2054, but not this. Concurrent callers would still race on the reset itself, and one thread would clear another's in-flight history.The conversation is per-task state, so it is constructed in the per-task entry point and threaded explicitly into the helpers that record into it (
route_task,handle_single_handoff,handle_multiple_handoffs). That fixes the race and removes the sequential leak in these two, since nothing survives the call.self.conversationis removed from both classes rather than left vestigial. No caller in the repo reads it, and leaving it would be a shared object that looks live but is never written.Testing
No new test.
tests/structs/test_multi_agent_router.pyis 22 failed / 1 passed / 1 skipped onmasterand identical on this branch — the suite needs live credentials, so a concurrency assertion added there would not run in CI. Happy to add one if you would rather have it behind a mockedfunction_caller.