DAO Proposals & Community
View active proposals, submit new ideas, and connect with the SWARMS community.
#2023 made `WORKSPACE_DIR` survive `import swarms`. Now that the value actually reaches the code that uses it, the two places acting on it have to cope with a value they don't control — a user-supplied path can be unwritable, or can name a file. ## `bootup.py` **Stops duplicating the defaulting logic.** `_prepare_workspace()` now defers to `workspace_manager.ensure_workspace_env()` instead of keeping a second copy. The two copies had already drifted: `ensure_workspace_env` clears `get_workspace_dir`'s `lru_cache` when it invents a default, and bootup's copy didn't — so a cached `None` could stick for the life of the process. **Guards `mkdir` on the caller's path.** It ran inside bootup's `try` block, which logs and **re-raises**, so a `WORKSPACE_DIR` that can't be created took down `import swarms` entirely. `exist_ok=True` doesn't help: it suppresses "already exists" only when the existing entry is a *directory*, so a `WORKSPACE_DIR` naming a file raised regardless. On failure it warns and falls back to `{cwd}/agent_workspace`. **The `workspace_manager` import is inside the function deliberately.** `swarms.utils.workspace_manager` pulls in `swarms/utils/__init__`, which calls `initialize_logger` at import. At module scope that runs *before* `bootup()`, so the logger would resolve its directory from the unprepared value. I hit this while writing the tests; the comment is there so it doesn't get "cleaned up" back to the top. ## `loguru_logger.py` **The same unguarded `makedirs` was here too — I introduced it in #2022**, one file away from the identical bug. It now degrades to console-only logging with a warning instead of raising. **Handlers are rebuilt when the log directory moves.** The `_CONFIGURED` guard from #2022 stops the 28 modules tearing down each other's handlers, but it also latched the *first* directory seen — and the first call happens before bootup settles `WORKSPACE_DIR`. After a fallback the logger stayed aimed at the unusable path and **wrote nothing anywhere**. Tracking `_CONFIGURED_DIR` fixes that; a changed directory is the one case where reconfiguring is correct. ## Tests `swarms/utils/loguru_logger.py` had **no coverage at all** before this. | file | tests | fail on master | | --- | --- | --- | | `tests/telemetry/test_bootup.py` | 5 | 2 | | `tests/utils/test_loguru_logger.py` | 13 | 2 | The four that fail on master are exactly the behaviours fixed here. The rest are regression guards for what #2022 and #2023 established: `log_folder` is a label rather than a path, per-module routing, non-`swarms` records ignored, size rotation, write errors swallowed, and `WORKSPACE_DIR` surviving import. `test_bootup.py` uses subprocesses because `WORKSPACE_DIR` is read during import, which happens once per interpreter — monkeypatching in-process would prove nothing. `test_loguru_logger.py` resets the module's process-wide state (`_CONFIGURED`, `_CONFIGURED_DIR`) and restores loguru's handler stack between tests; without that they leak into each other and into neighbouring files. I verified it perturbs nothing: `tests/utils/` + `tests/telemetry/` show the same 24 failures with and without the new file. ## Carried along, not authored here Flagged so a reviewer can scope them separately: | change | note | | --- | --- | | `pyproject.toml` | version `14.0.0` → `14.0.1` | | `auto_agent_builder.py` | adds `batch_run` delegating to `execution_utils.batched_run` | | `structs/concat.py` **deleted** | verified: nothing imports it, and it was never exported from `structs/__init__` | ## Verification - full suite vs master, same test files both sides: **no failures introduced**; the only difference is the four tests that now pass - `import swarms` works with `WORKSPACE_DIR` set, unset, and pointing at an unusable path - an unusable path falls back and logs still land in the fallback (previously: nothing written) - `black --line-length 70 --check` and `ruff check` clean across 277 files
Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/astral-sh/ruff/releases">ruff's releases</a>.</em></p> <blockquote> <h2>0.16.4</h2> <h2>Release Notes</h2> <p>Released on 2026-08-20.</p> <h3>Preview features</h3> <ul> <li>[<code>flake8-use-pathlib</code>] Add autofix for <code>PTH116</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/26460">#26460</a>)</li> <li>[<code>refurb</code>] Restrict <code>delete-full-slice</code> to lists (<code>FURB131</code>) (<a href="https://redirect.github.com/astral-sh/ruff/pull/27711">#27711</a>)</li> <li>[<code>refurb</code>] Skip <code>FURB101</code> and <code>FURB103</code> when the <code>open</code> argument is a file descriptor (<a href="https://redirect.github.com/astral-sh/ruff/pull/27643">#27643</a>)</li> </ul> <h3>Bug fixes</h3> <ul> <li>Fix <code>InvalidInstruction</code> on Windows CPUs that do not support <code>POPCNT</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/27803">#27803</a>)</li> <li>[<code>pyflakes</code>] Emit semantic syntax errors in string type definitions as <code>F722</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/27835">#27835</a>)</li> <li>[<code>pylint</code>] Allow <code>os._exit</code> imports in <code>import-private-name</code> (<code>PLC2701</code>) (<a href="https://redirect.github.com/astral-sh/ruff/pull/27738">#27738</a>)</li> </ul> <h3>Rule changes</h3> <ul> <li>[syntax-errors] Align mixed t-string/bytes error message with CPython 3.14 (<a href="https://redirect.github.com/astral-sh/ruff/pull/27766">#27766</a>)</li> <li>[<code>ruff</code>] Add <code>ctypes.LittleEndianStructure</code> and related types to existing exception (<code>RUF012</code>) (<a href="https://redirect.github.com/astral-sh/ruff/pull/27753">#27753</a>)</li> <li>[syntax-errors] Detect duplicate keyword arguments (<a href="https://redirect.github.com/astral-sh/ruff/pull/17804">#17804</a>)</li> <li>[syntax-errors] Detect parameters declared <code>nonlocal</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/27628">#27628</a>)</li> </ul> <h3>Server</h3> <ul> <li>Offer display-only fixes and mark safe fixes preferred (<a href="https://redirect.github.com/astral-sh/ruff/pull/27807">#27807</a>)</li> <li>Support pull diagnostics for notebook cells (<a href="https://redirect.github.com/astral-sh/ruff/pull/27779">#27779</a>)</li> </ul> <h3>Documentation</h3> <ul> <li>Add default indicator to rules table (<a href="https://redirect.github.com/astral-sh/ruff/pull/27724">#27724</a>)</li> <li>Fix broken link to Python docs (<a href="https://redirect.github.com/astral-sh/ruff/pull/27757">#27757</a>)</li> </ul> <h3>Other changes</h3> <ul> <li>Fix s390x stacker assembly in release builds (<a href="https://redirect.github.com/astral-sh/ruff/pull/27776">#27776</a>)</li> <li>Guarantee minimum stack size when parsing a module, standalone expression, and suites (<a href="https://redirect.github.com/astral-sh/ruff/pull/25464">#25464</a>)</li> <li>Reduce configuration deserialization code size (<a href="https://redirect.github.com/astral-sh/ruff/pull/27924">#27924</a>)</li> <li>Check packed AST index bounds (<a href="https://redirect.github.com/astral-sh/ruff/pull/27849">#27849</a>)</li> </ul> <h3>Contributors</h3> <ul> <li><a href="https://github.com/AbhinavMir"><code>@AbhinavMir</code></a></li> <li><a href="https://github.com/eduardorittner"><code>@eduardorittner</code></a></li> <li><a href="https://github.com/royb3"><code>@royb3</code></a></li> <li><a href="https://github.com/MichaReiser"><code>@MichaReiser</code></a></li> <li><a href="https://github.com/carljm"><code>@carljm</code></a></li> <li><a href="https://github.com/rosstitmarsh"><code>@rosstitmarsh</code></a></li> <li><a href="https://github.com/ntBre"><code>@ntBre</code></a></li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's changelog</a>.</em></p> <blockquote> <h2>0.16.4</h2> <p>Released on 2026-08-20.</p> <h3>Preview features</h3> <ul> <li>[<code>flake8-use-pathlib</code>] Add autofix for <code>PTH116</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/26460">#26460</a>)</li> <li>[<code>refurb</code>] Restrict <code>delete-full-slice</code> to lists (<code>FURB131</code>) (<a href="https://redirect.github.com/astral-sh/ruff/pull/27711">#27711</a>)</li> <li>[<code>refurb</code>] Skip <code>FURB101</code> and <code>FURB103</code> when the <code>open</code> argument is a file descriptor (<a href="https://redirect.github.com/astral-sh/ruff/pull/27643">#27643</a>)</li> </ul> <h3>Bug fixes</h3> <ul> <li>Fix <code>InvalidInstruction</code> on Windows CPUs that do not support <code>POPCNT</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/27803">#27803</a>)</li> <li>[<code>pyflakes</code>] Emit semantic syntax errors in string type definitions as <code>F722</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/27835">#27835</a>)</li> <li>[<code>pylint</code>] Allow <code>os._exit</code> imports in <code>import-private-name</code> (<code>PLC2701</code>) (<a href="https://redirect.github.com/astral-sh/ruff/pull/27738">#27738</a>)</li> </ul> <h3>Rule changes</h3> <ul> <li>[syntax-errors] Align mixed t-string/bytes error message with CPython 3.14 (<a href="https://redirect.github.com/astral-sh/ruff/pull/27766">#27766</a>)</li> <li>[<code>ruff</code>] Add <code>ctypes.LittleEndianStructure</code> and related types to existing exception (<code>RUF012</code>) (<a href="https://redirect.github.com/astral-sh/ruff/pull/27753">#27753</a>)</li> <li>[syntax-errors] Detect duplicate keyword arguments (<a href="https://redirect.github.com/astral-sh/ruff/pull/17804">#17804</a>)</li> <li>[syntax-errors] Detect parameters declared <code>nonlocal</code> (<a href="https://redirect.github.com/astral-sh/ruff/pull/27628">#27628</a>)</li> </ul> <h3>Server</h3> <ul> <li>Offer display-only fixes and mark safe fixes preferred (<a href="https://redirect.github.com/astral-sh/ruff/pull/27807">#27807</a>)</li> <li>Support pull diagnostics for notebook cells (<a href="https://redirect.github.com/astral-sh/ruff/pull/27779">#27779</a>)</li> </ul> <h3>Documentation</h3> <ul> <li>Add default indicator to rules table (<a href="https://redirect.github.com/astral-sh/ruff/pull/27724">#27724</a>)</li> <li>Fix broken link to Python docs (<a href="https://redirect.github.com/astral-sh/ruff/pull/27757">#27757</a>)</li> </ul> <h3>Other changes</h3> <ul> <li>Fix s390x stacker assembly in release builds (<a href="https://redirect.github.com/astral-sh/ruff/pull/27776">#27776</a>)</li> <li>Guarantee minimum stack size when parsing a module, standalone expression, and suites (<a href="https://redirect.github.com/astral-sh/ruff/pull/25464">#25464</a>)</li> <li>Reduce configuration deserialization code size (<a href="https://redirect.github.com/astral-sh/ruff/pull/27924">#27924</a>)</li> <li>Check packed AST index bounds (<a href="https://redirect.github.com/astral-sh/ruff/pull/27849">#27849</a>)</li> </ul> <h3>Contributors</h3> <ul> <li><a href="https://github.com/AbhinavMir"><code>@AbhinavMir</code></a></li> <li><a href="https://github.com/eduardorittner"><code>@eduardorittner</code></a></li> <li><a href="https://github.com/royb3"><code>@royb3</code></a></li> <li><a href="https://github.com/MichaReiser"><code>@MichaReiser</code></a></li> <li><a href="https://github.com/carljm"><code>@carljm</code></a></li> <li><a href="https://github.com/rosstitmarsh"><code>@rosstitmarsh</code></a></li> <li><a href="https://github.com/ntBre"><code>@ntBre</code></a></li> <li><a href="https://github.com/zaniebot"><code>@zaniebot</code></a></li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/astral-sh/ruff/commit/11c76bf48fdac06b2f240cba502eda96da4dce77"><code>11c76bf</code></a> Bump 0.16.4 (<a href="https://redirect.github.com/astral-sh/ruff/issues/27937">#27937</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/d53c8c58662ca0576ddd502aa1a2979acf03832f"><code>d53c8c5</code></a> Isolate playground builds from deployment credentials (<a href="https://redirect.github.com/astral-sh/ruff/issues/27839">#27839</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/cab001e5dec22f55653021f1f7be449e47c7d81e"><code>cab001e</code></a> Disable uv preview for releases and pre-commit hooks (<a href="https://redirect.github.com/astral-sh/ruff/issues/27939">#27939</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/f8d575fedc97e75ea62c679d77afb14246afa88e"><code>f8d575f</code></a> [ty] Clarify writing guidance for human readers (<a href="https://redirect.github.com/astral-sh/ruff/issues/27912">#27912</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/ca45faebb1750a213df19ed7f686ee5cf9277f93"><code>ca45fae</code></a> Set <code>--preview</code> and <code>--default-index</code> for the <code>uv-lock</code> hook (<a href="https://redirect.github.com/astral-sh/ruff/issues/27935">#27935</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/4827bf7cb449055e46fbfaf4b26e5125883a0569"><code>4827bf7</code></a> Export <code>UV_DEFAULT_INDEX</code> in <code>release.sh</code> (<a href="https://redirect.github.com/astral-sh/ruff/issues/27934">#27934</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/d1087a4b9e03d253a88703f34e0869ee4b805456"><code>d1087a4</code></a> [ty] Handle assignment expressions in string annotations (<a href="https://redirect.github.com/astral-sh/ruff/issues/27921">#27921</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/680cce48b6d89ab5b1566e4b797bd4847d861815"><code>680cce4</code></a> [ty] Optimize inherited recursive protocol comparisons (<a href="https://redirect.github.com/astral-sh/ruff/issues/27922">#27922</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/974d3cbc04520c112843d6b92577844587402e04"><code>974d3cb</code></a> Upgrade ecosystem-analyzer and mypy_primer to the latest upstream pins (<a href="https://redirect.github.com/astral-sh/ruff/issues/27932">#27932</a>)</li> <li><a href="https://github.com/astral-sh/ruff/commit/b169b402356d0676451f4a7bc6903da2644b31eb"><code>b169b40</code></a> Install cargo tools locked (<a href="https://redirect.github.com/astral-sh/ruff/issues/27929">#27929</a>)</li> <li>Additional commits viewable in <a href="https://github.com/astral-sh/ruff/compare/0.5.1...0.16.4">compare view</a></li> </ul> </details> <br /> Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details>
Closes #2010 > **Stacked on #2014.** That PR's commit is the parent here — review/merge it first. It fixes the `Agent.load()` crash that the revived save/load test reproduces on its very first run, so this branch cannot go green without it. The diff above the parent is one file. ## The result ``` master 19 failed, 78 passed, 8 errors here 19 failed, 84 passed, 0 errors ``` The 19 failures are pre-existing and the set is byte-identical — diffed against a clean master worktree, not eyeballed. ## What was wrong `6f4803ef` (2025-10-21) deleted `mocked_llm` but left behind the two fixtures that request it, and the eight tests that request those: ``` fixture 'mocked_llm' not found ``` Because they *error* rather than fail, they read as infrastructure noise. They have looked like coverage for ten months. ## Restoring the fixture is not the whole job With `mocked_llm` back, three of the eight still fail — they assert an `Agent` API that has not existed for years. That is worth seeing, because it is the drift the erroring hid: | test | assumed | reality | |---|---|---| | `test_flow_initialization` | `.feedback`, `.memory`, `max_loops == 5` | neither attribute exists; the fixture passes `max_loops=1` | | `test_provide_feedback` | `.provide_feedback()` | method gone | | `test_format_prompt` | `.format_prompt()` | method gone | | `test_save_and_load` | `.memory` list | it is `short_memory`, a `Conversation` | | `test_flow_call` | `agent("x") == "x"` | only ever true of the deleted mock | So: - **Deleted** `test_provide_feedback` and `test_format_prompt`. Their subject does not exist; there is nothing to assert. - **Rewrote** `test_flow_initialization` against the attributes that do exist, with the `max_loops` assertion matching the fixture rather than contradicting it. - **Rewrote** `test_save_and_load` as a real scalar round trip through the Agent API — `max_loops` 9 → 3, `agent_name` restored. `load()` deliberately preserves live instances rather than rehydrating them, so the conversation is not part of the round trip and the test no longer pretends otherwise. - **Rewrote** `test_flow_call` to pin the delegation (`__call__` forwards to `run()`). Comparing two live calls does not work — the conversation grows between them, so the second returns something different. The other three (`test_bulk_run`, both `test_run_*`) needed nothing but the fixture back. ## The fixture `mocked_llm` is a stub with `run`/`arun` that echoes the task, so these need no provider. The two agent fixtures now also pin a `tmp_path` workspace and disable `autosave` and `persistent_memory`, so the revived tests write nothing to the repo — worth doing given they had not run in ten months and nobody knew what they touched. ## Why the stack matters The first thing the revived `test_save_and_load` did was reproduce a live bug: ``` AttributeError: property 'workspace' of 'Agent' object has no setter ``` `Agent.load()` was raising for *every* agent. That is #2014, and it is exactly the cost @Steve-Dusty describes in #2010 — the test was there the whole time, and it was not running. `black==24.2.0 --check .` and `ruff==0.2.1 check .` clean across the tree.
Eight tests in `tests/structs/test_agent.py` have not executed since 2025-10-21. They error at setup on a fixture that no longer exists, so they report as errors rather than failures and the suite carries them as permanent noise. One of the eight is `TestBasicAgent::test_save_and_load` — the only round-trip test `Agent.save()`/`Agent.load()` had. #1994 is a broken save/load round trip that shipped in a repo that has had a save/load test the whole time. That is the cost of leaving these erroring: they look like coverage and are not. ## Root cause `tests/structs/test_agent.py:44` and `:50` request a fixture that is defined nowhere: ```python @pytest.fixture def basic_flow(mocked_llm): """Basic agent fixture""" return Agent(llm=mocked_llm, max_loops=1) @pytest.fixture def flow_with_condition(mocked_llm): ``` `def mocked_llm` was deleted by `6f4803ef` (2025-10-21, *"[DELELTE OLD TESTS] [Tests][Agent] ..."*) while the two fixtures that take it, and the eight tests that take those, were left in place: ``` $ git show 6f4803ef -- tests/ | grep '^-def mocked_llm' -def mocked_llm(): ``` Nothing else defines it, and there is no `conftest.py` anywhere under `tests/` for it to have moved into: ``` $ grep -rn "mocked_llm" tests/ tests/structs/test_agent.py:44:def basic_flow(mocked_llm): tests/structs/test_agent.py:46: return Agent(llm=mocked_llm, max_loops=1) tests/structs/test_agent.py:50:def flow_with_condition(mocked_llm): tests/structs/test_agent.py:55: llm=mocked_llm, $ find tests -name conftest.py (nothing) ``` ## Reproducer ```bash pytest tests/structs/test_agent.py -q ``` Output on `master` @ `d34de4e5`: ``` 19 failed, 78 passed, 8 errors in 10.62s ``` ``` $ pytest tests/structs/test_agent.py::TestBasicAgent::test_save_and_load ERROR at setup of TestBasicAgent.test_save_and_load fixture 'mocked_llm' not found ``` The eight: ``` TestBasicAgent::test_flow_initialization TestBasicAgent::test_provide_feedback TestBasicAgent::test_run_without_stopping_condition TestBasicAgent::test_run_with_stopping_condition TestBasicAgent::test_bulk_run TestBasicAgent::test_save_and_load TestBasicAgent::test_flow_call TestBasicAgent::test_format_prompt ``` (`TestBasicAgent::test_stop_when_repeats` is the ninth method in the class and passes — it takes no fixture.) These also show up in CI: the `test` job's 20 errors include all eight. ## Suggested change Restore the fixture rather than delete the tests. A mock is enough — none of the eight needs a real provider: ```python @pytest.fixture def mocked_llm(): """Stand-in LLM. These tests assert agent plumbing, not model output.""" llm = MagicMock() llm.run.return_value = "mocked response" llm.__call__ = MagicMock(return_value="mocked response") return llm ``` `MagicMock` is already imported at the top of the file. Two of the eight need more than that and should be checked individually while restoring: `test_flow_initialization` asserts `basic_flow.max_loops == 5` while the fixture builds the agent with `max_loops=1`, so it was failing on its own terms before it started erroring. The alternative — deleting `TestBasicAgent` — is worse. `test_save_and_load` is the one place the round trip #1994 describes was meant to be covered, and removing it would make the gap permanent instead of visible. ## Acceptance criteria - `pytest tests/structs/test_agent.py -q` reports **0 errors**. - `test_save_and_load` executes and asserts that `load()` reads back what `save()` wrote. ## Environment `master` @ `d34de4e5` (v14.0.0), Python 3.12, macOS.
Closes #1994. `Agent.load()` built the state-file path differently from `Agent.save()`, so **a file `save()` had written seconds earlier was unreadable** — `load()` raised `FileNotFoundError` for the agent's own state. This is fault 1 of #1994; fault 2 is deliberately not here, for a reason given at the bottom. ## Problem `save()` (`agent.py:2712`) added the extension only when missing and joined the agent workspace. `load()` (`agent.py:2857`) did neither: ```python f"{self.saved_state_path}.json" if self.saved_state_path ``` Two faults in that one expression: `.json` appended to a name that already ends in `.json`, and no workspace join, so the relative name resolved against the process cwd. Reproducer — a bare `Agent` carrying only the attributes the path code reads, so it runs offline: ``` $ python repro_1994.py # master @ d34de4e5 saved_state_path : my_agent_state.json save() wrote to : <workspace>/agents/boss-6fd6a53ba3f2/my_agent_state.json load() reads from: my_agent_state.json.json same file? : False load path exists?: False ``` **`autosave=True` is the pattern this breaks.** `CLAUDE.md` recommends it for long-running agents, and there are five internal `self.save()` call sites in the run loop — so state was being written on schedule and was never readable back through the documented API. ## Fix Both sides go through one resolver, so they cannot drift again: ```python def _resolve_state_file(self, candidate: Optional[str]) -> Optional[str]: if not candidate: return None if not candidate.endswith(".json"): candidate = f"{candidate}.json" if os.path.isabs(candidate): return candidate return os.path.join(self._get_agent_workspace_dir(), candidate) ``` `load_state_path` keeps its precedence over `saved_state_path`; only the final path construction is shared. After: ``` save() wrote to : <workspace>/agents/boss-6fd6a53ba3f2/my_agent_state.json load() reads from: <workspace>/agents/boss-6fd6a53ba3f2/my_agent_state.json same file? : True load path exists?: True ``` ## Two smaller mismatches in the same expression - **The last-resort filename disagreed too.** `load()` fell back to `f"{agent_name}.json"`, `save()` to `f"{agent_name}_state.json"` — the same class of bug one level down, reached whenever `saved_state_path` is unset. `load()` now uses `save()`'s name. - **The final fallback was unreachable.** `f"{workspace_dir}/{agent_name}_state.json"` sat in the `else` branch of `if self.agent_name`, behind a `workspace_dir and self.agent_name` guard — so it could only be *evaluated* when `agent_name` was falsy and could only *fire* when it was truthy. It has never produced a path. Removed rather than carried into the new expression. ## Behavior-change note A **relative** `file_path` passed to `load()` now resolves against the agent workspace instead of the cwd. Anyone who was passing a relative path and relying on cwd resolution will need an absolute path — which `_resolve_state_file` passes through untouched. This is the fix, not a side effect: cwd resolution is precisely why `load()` could not find `save()`'s output. `save()`'s behaviour for absolute and relative paths is unchanged; its old explicit `isabs` branch was already equivalent to the `os.path.join` it fell through to, since `join` discards earlier components when a later one is absolute. ## Verification **The tests fail on unfixed `master`.** Five of the eight, for the right reason — the real `save()` and `load()` disagreeing about a real path: ``` E AssertionError: assert '/private/var/.../agents/boss-6fd6a53ba3f2/my_agent_state.json' == 'my_agent_state.json.json' E AssertionError: assert not 'my_agent_state.json.json'.endswith('.json.json') E AssertionError: assert False = os.path.isabs('my_agent_state.json.json') ``` Being precise about the other three, since "8 of 8 fail" would overstate it: two of them (`test_an_absolute_path_is_used_as_given`, `test_no_candidate_resolves_to_none`) fail on `master` only with `AttributeError: no attribute '_resolve_state_file'` — they pin the resolver's contract, they are not regression tests. The eighth, `test_load_state_path_still_wins_over_saved_state_path`, **passes on `master`** by design: it guards precedence that this PR must not change. **Suite**, `tests/structs/test_agent.py`: ``` master @ d34de4e5 : 19 failed, 78 passed, 8 errors this branch : 19 failed, 86 passed, 8 errors ``` +8, no new failures. The 19 failures and 8 errors are pre-existing and identical on untouched `master` — the errors are all `TestBasicAgent`, which cannot run at all (`fixture 'mocked_llm' not found`). Worth naming, because one of the eight is `TestBasicAgent::test_save_and_load`: there has been a save/load test in this file the whole time and it has never executed. That is how a broken round trip stayed unnoticed. **Lint**, with the versions `lint.yml` pins (`black==24.2.0`, `ruff==0.2.1`, line-length 70): ``` $ black . --check --diff 984 files would be left unchanged $ ruff check . (clean) ``` **Not verified here**: no provider call and no real agent construction. The tests build agents with `Agent.__new__` carrying only the attributes the path code reads, and stub `SafeStateManager` at both ends — the point is which path each function picks, not what gets serialised into it. Precedent for that shape is `TestToolExecutionRetry` in the same file. What is *not* covered is whether the state written round-trips semantically; that is `SafeStateManager`'s contract and this PR does not touch it. ## What I deliberately left out **Fault 2 — the workspace directory is random per construction.** `_get_agent_workspace_dir` keys on `self.id`, regenerated on every `Agent(...)` unless the caller passes `id=`, so a restarted process still cannot find what the previous one saved. Fixing it means keying the directory on something durable and **moving where existing agents write**, which is a decision rather than a patch — @ayaangazali flagged the same thing when filing. So this PR makes the same-process round trip correct and leaves the cross-process one to whoever makes that call. I noted the constraint in `_resolve_state_file`'s docstring so the next reader does not mistake this for a complete fix. **Relationship to #1958**: that PR stops `saved_state_path` being overwritten in `__init__`; these are separate, pre-existing defects in how the path is *built*, and they are why the round trip still fails after it. The two touch different regions of `agent.py` — `__init__` there, `save`/`load` here — so they compose. Test-merged rather than assumed: ``` $ git merge --no-commit --no-ff fix/state-file-path-round-trip-1994 # onto #1958's head Auto-merging swarms/structs/agent.py Auto-merging tests/structs/test_agent.py Automatic merge went well $ pytest tests/structs/test_agent.py -q 19 failed, 89 passed, 8 errors # 78 baseline + 8 here + 3 from #1958 ``` Both PRs' new test classes pass together, 11 of 11. Either merge order works, and nothing is needed from @ayaangazali.
EvalPort (https://github.com/adhabnr-ux/evalport) is an open JSON-Schema spec for portable LLM eval documents — test suites, test cases, and result sets — so eval data isn't locked to one framework's format. I went through `swarms/structs/council_as_judge.py` and think `CouncilAsAJudge` is a genuinely good fit. It already evaluates a response across six well-defined dimensions (`EVAL_DIMENSIONS`: accuracy, helpfulness, harmlessness, coherence, conciseness, instruction_adherence), runs each as a parallel judge `Agent`, and aggregates the rationales — but the output today is free text via `history_output_formatter()`. There's no structured per-dimension result, so a `CouncilAsAJudge` run can't be diffed against a previous run, compared across model swaps, or fed into any external eval dashboard without hand-parsing prose. Mapping `all_rationales` onto EvalPort's ResultSet (`spec/schemas/resultset.json`) is pretty direct — each dimension becomes a `grader_result`: ```python # swarms/structs/council_as_judge.py currently collects # all_rationales: Dict[str, str] (one rationale per EVAL_DIMENSIONS key) # and only ever renders it through history_output_formatter(). # A structured export alongside that would look like: def council_result_to_resultset(all_rationales: dict, task: str, run_id: str) -> dict: return { "version": "1.0.0", "suite_id": "swarms_council_as_judge", "run_id": run_id, "started_at": "...", # ISO 8601 "results": [{ "test_case_id": task[:64], "actual_output": task, "grader_results": [ { "grader_id": dim, "type": "llm_judge", "score": None, # CouncilAsAJudge is qualitative today, no numeric score "passed": True, # or derive from a scoring convention you add "reason": rationale, } for dim, rationale in all_rationales.items() ], "passed": True, }], } ``` That's a small addition (an `output_type="evalport"` branch, maybe, or a standalone helper), and it would let anyone running `CouncilAsAJudge` snapshot results in a format that's diffable and comparable outside swarms itself — including against results from other frameworks' judges, since ResultSet isn't swarms-specific. Spec: https://github.com/adhabnr-ux/evalport/blob/main/SPEC.md Schema referenced above: https://github.com/adhabnr-ux/evalport/blob/main/spec/schemas/resultset.json No pressure at all if this isn't a priority right now — happy to open a PR sketch if it's useful, or happy to just leave this here for whenever it's relevant. Thanks for swarms, it's a great project.
## Summary When a sequence of steps succeeds, compile it into a named macro tool that later runs can find via `tool_search`. This is self-modification where the artifact is **data, not code** - so it is reviewable, diffable, and revertible, and it needs no new trust model. ## Today Nothing survives a run except the final output. An agent that works out a good six-step sequence for a recurring job re-derives it from scratch every time. ## Proposal After a successful run, offer to compile a completed subtask sequence into a skill: ```yaml name: publish_release_notes description: Collect merged PRs since the last tag and write release notes. steps: - read the git log since the previous tag - group changes by type - write notes to RELEASES.md ``` Skills go into the same catalog as any other deferred tool, so they are searchable and loadable through the existing mechanism. Invoking one seeds the plan with its steps rather than executing opaque code. ## Why this is the safest form of self-modification A skill is a **plan fragment**, not executable code. It can be diffed in review, stored in the repo, edited by hand, and deleted. It cannot do anything the agent could not already do with the tools it has - it only shortens the path. Compare tool synthesis, which introduces new executable code and needs sandboxing, provenance, and a trust decision. It also compounds in the right direction: skills accumulate as *shared, inspectable* capability rather than as opaque tuning inside a model's context. ## Notes - **Promotion criteria.** A sequence should not become a skill on one success. Require repetition, or explicit approval. - **Parameterisation.** A skill hard-coded to one path is nearly useless; extracting the variables is the real work. - **Staleness.** A skill referencing a tool that no longer exists must fail loudly, not silently half-run. ## Related Uses the dynamic tool loader and the mutable plan (#1978). A safer alternative to tool synthesis. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary Let the agent write a Python function during a run and register it as a callable tool. `DynamicToolLoader.register()` already does exactly this at runtime - the plumbing exists. What is missing is everything around it: sandboxing, persistence, and trust. ## Today `DynamicToolLoader.register(*tools)` (`swarms/tools/dynamic_tool_loader.py:197`) accepts callables, converts them to schemas, and puts them in the searchable catalog. Nothing structural stops an agent-authored function from going in the same way. ## Proposal A `create_tool(name, source, description)` tool that compiles the source, registers it, and makes it searchable from the next turn - the same path a deferred tool already takes. ## The hard parts, which are not the plumbing **Sandboxing.** Executing model-authored code is a materially different risk from executing tools a developer chose. This should not ship before a real permission layer exists (#1980); the current substring blocklist is not a foundation to build this on. **Lifetime.** Does a synthesised tool survive the process? In-memory only is safe but discards the value. Persisting it means agent-authored code is loaded automatically on the next run, which is a much larger trust decision and needs review before promotion. **Verification.** A synthesised tool should have to demonstrate it works - run against a stated example, with the result checked - before it is offered to the model as though it were reliable. **Namespace.** Collisions with existing tools, and with `tool_search` itself, must be rejected rather than shadowing. ## Recommendation Prototype in-memory and session-scoped, behind an explicit flag, with no persistence. Treat persistence as a separate decision requiring the permission layer. ## Related Should not land before #1980. Uses the dynamic tool loader. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary `max_subtask_loops`, `temperature`, and model choice are fixed for the life of an agent regardless of what the task turns out to need. A simple lookup gets the same 20-iteration budget as a research task; a hard task gets the same temperature as a mechanical one. ## Today `MAX_SUBTASK_ITERATIONS` and `MAX_SUBTASK_LOOPS` are module constants (`swarms/structs/autonomous_loop_utils.py:46`) - not even constructor parameters. `temperature` and `model_name` are set once at construction. ## Proposal Adapt a small number of parameters to observed difficulty, measured from signals the loop already produces: | signal | adaptation | | --- | --- | | subtask completed in 1-2 iterations | lower the budget for similar steps | | repeated retries or tool errors | raise the budget; consider a stronger model | | stagnation (repeated identical calls) | raise temperature to break the pattern | | plan revised more than once | the task is harder than it looked - widen the budget | Two prerequisites, both worth doing regardless: make the caps **constructor parameters** rather than module constants, and make every adaptation **logged and inspectable**. ## Notes Keep the scope narrow. Self-tuning that touches many parameters at once cannot be attributed or debugged, and drifts invisibly. Start with the iteration budget alone, which is measurable and reversible. ## Related Interacts with budget accounting (#1976) and budget-aware degradation. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary Every run plans from a cold start, even when the agent has solved the same shape of task before. Remembering plan skeletons that worked and offering them at planning time turns planning from generation into recall. ## Today The planning phase asks the model to produce a plan from the task text alone. Two runs of the same recurring job produce two independently invented plans, with the variance that implies. ## Proposal On successful completion, store the plan skeleton - step ids, descriptions, dependencies, and the outcome - keyed by a task signature. At planning time, retrieve close matches and include them in the planning prompt as prior art: > A similar task previously succeeded with this plan: ... Adapt it, or plan fresh if it does not fit. Offered, not imposed. The model must stay free to ignore a template that does not apply, or this becomes a way to repeat a bad plan forever. ## Notes - **Task signature** is the hard part. Start with embedding similarity over the task text, or an explicit `task_kind` the caller supplies - the latter is unglamorous and works. - **Store outcomes, not just plans.** A skeleton that succeeded twice and failed five times is a warning, not a template. - **Templates go stale** as tools and the environment change. Age them out, or verify before offering. ## Related Depends on the mutable plan (#1978). Complements persisted lessons and tool statistics. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary `DynamicToolLoader` ranks candidate tools by token overlap alone. It has no idea which tools have actually worked. Recording per-tool outcomes and using them as a ranking prior is cheap, and it compounds with use. ## Today `DynamicToolLoader.search` scores on name and description overlap (`swarms/tools/dynamic_tool_loader.py`). Every tool is equally plausible on its first and thousandth use, and a tool that has failed every time it was loaded ranks exactly as high as one that always works. ## Proposal Record outcomes per `(tool, task-kind)` - invocation count, success rate, and whether a loaded tool was ever actually called - and fold them into the existing score: ``` score = text_overlap * (1 + w * historical_success) ``` The scoring function is already a single place, so this is an added term rather than a rewrite. Two signals worth capturing separately: - **Was it loaded and never used?** That is a search precision failure - the query matched something irrelevant. - **Was it used and did it fail?** That is a tool quality signal, and a reason to rank an alternative higher. ## Notes - Persist alongside `MEMORY.md` so statistics survive restarts. - Keep a floor so a tool that failed once never becomes unreachable. - Statistics must be inspectable; a ranking that shifts invisibly is hard to debug. ## Related Extends the dynamic tool loader; pairs with persisted lessons. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary `complete_task` asks the model for `lessons_learned`, formats it into the final summary, and then throws it away. The next run of the same agent starts with no memory of it. Persisting it is nearly free - the substrate already exists. ## Today `_complete_task_tool` (`swarms/structs/agent.py:3881`) accepts `lessons_learned`, appends it to a summary string, and adds that string to `short_memory` - the *per-run* conversation. When the run ends it is gone. Meanwhile `persistent_memory` already reads and writes `{workspace}/agents/{agent_name}-{id}/MEMORY.md` across restarts (`agent.py:634`). The two features never meet. ## Proposal When `persistent_memory` is enabled, append the task's lessons to `MEMORY.md` on completion, under a dated heading, and let the existing preamble injection surface them on the next run. Worth getting right: - **Bound the growth.** Cap the section and let `ContextCompressor` summarise older entries, or lessons accumulate until they crowd out the task. - **Keep them attributable.** Store the task alongside the lesson; a lesson without its context is noise. - **Only on success paths worth learning from.** A lesson from a run that failed for environmental reasons is often misleading. ## Impact This is the smallest possible step toward an agent that improves with use, and it reuses two features that already work. ## Related Uses `persistent_memory`; interacts with `ContextCompressor` (#1962). --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary The loop runs at full exploration until it hits a hard limit, then stops mid-thought. There is no wind-down. Failing gracefully at 80% of budget produces a far more useful result than hitting a wall at 100%. ## Today `MAX_SUBTASK_ITERATIONS = 100` and `MAX_SUBTASK_LOOPS = 20` (`swarms/structs/autonomous_loop_utils.py:46`) are the only brakes, and they are binary: under the limit the agent behaves identically, over it the work stops. There is no token or cost budget at all (#1976). ## Proposal Track budget consumption (iterations, and tokens once #1976 lands) and change mode as it depletes: | remaining | mode | | --- | --- | | > 50% | normal - explore, verify, refine | | 20-50% | **focus** - no new subtasks, no optional verification, finish what is planned | | < 20% | **consolidate** - stop tool use, summarise what is known, produce best-effort output | | 0% | stop, with an explicit "budget exhausted" status rather than a silent cap | Implementation is mostly prompt injection at mode changes plus a check before selecting the next subtask - no new control flow. ## Why it matters A run that spends its whole budget exploring and is then cut off returns nothing usable. A run that notices at 80% and spends the last 20% writing up what it found returns most of the value. The second is strictly better and costs the same. ## Related Needs the accounting from #1976. Complements #1974 (honest reporting of incomplete work). --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary `tool_execution_retry` retries a failed tool call by re-running the identical call. If the call failed because the arguments were wrong, retrying unchanged fails identically. The number of retries matters far less than whether anything varies between them. ## Today `swarms/structs/agent.py:4418`: ```python for attempt in range(1, attempts + 1): try: self.execute_tools(response=response, loop_count=loop_count) return except Exception as e: last_error = e ``` `response` is unchanged across attempts. Nothing about the failure feeds back into the next try. ## Proposal Replace the flat retry count with an escalation ladder, stopping as soon as one rung succeeds: 1. **Retry as-is** - covers genuinely transient failures (timeouts, rate limits). Add backoff here. 2. **Retry with corrected arguments** - hand the model the error and the original arguments, and let it produce a corrected call. This is where most real recoveries live: bad path, wrong type, missing required field. 3. **Try a different tool** - with dynamic tools available, `tool_search` can surface an alternative for the same intent. 4. **Decompose** - ask for the step to be split into smaller ones, which also feeds the replanning path. Each rung is a strictly larger intervention, so the cheap ones run first. ## Notes - Rung 2 costs an LLM turn, so it should be gated on the error looking like an argument problem rather than a network problem. - The ladder should be configurable, and the current behaviour (rung 1 only) must remain expressible. ## Related Rung 2 depends on #1963 (tool errors reaching the model). Rung 4 overlaps with failure-triggered replanning. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
## Summary When a subtask fails, the run currently does the honest thing and stops: dependents are marked `skipped` and the summary reports the gap. It never tries to *route around* the failure. One repair turn would let it recover, and every piece needed is already in place. ## Today `_subtask_done_tool` (`swarms/agents/autonomous_loop.py:1561`) records `failed`, `_get_next_executable_subtask` marks the dependents `skipped`, and execution winds down. The model is never asked whether the plan could be rewritten to reach the goal another way. ## Proposal On a subtask failure (or an exhausted iteration budget), inject one repair turn before cascading: > Subtask `fetch_prices` failed: `ConnectionError: api.example.com unreachable`. Revise the plan to reach the goal another way, or confirm the task cannot be completed. Then let the model call `create_plan`. That is the whole mechanism. **Everything it needs already exists:** - Plans are mutable and merge by `step_id` (#1978), so a revision preserves completed work rather than restarting. - Tool errors reach the model as results (#1963), so the failure is describable rather than invisible. - The structured transcript (#1977) means the repair turn sees the real tool call that failed. **Bounding it.** Cap repair attempts per subtask (1-2) and count them against the run budget, or a plan that cannot succeed becomes a replanning loop. If the repair does not produce a materially different plan, fall through to the current cascade. ## Impact This is the difference between an agent that stops at the first obstacle and one that works around it - probably the highest-value single change available, and roughly a day's work given the primitives. ## Related Builds on #1978, #1966, #1963. Bounded by #1976. --- From a design review of self-healing and self-modification options for the `max_loops="auto"` loop.
Split out of #1958, where @kyegomez spotted it. That PR fixes `saved_state_path` being overwritten in `__init__`; these two are separate, pre-existing defects in how the path is *built*, and they are why the round trip still does not work after it. ## 1. `load()` and `save()` disagree on the filename and the directory `save()` (`agent.py:2323`) adds the extension only when it is missing, and resolves a relative name against the agent workspace: ```python resolved_path = ( file_path or self.saved_state_path or f"{self.agent_name}_state.json" ) if not resolved_path.endswith(".json"): resolved_path += ".json" ... full_path = os.path.join(agent_workspace, resolved_path) ``` `load()` (`agent.py:2451`) does neither: ```python f"{self.saved_state_path}.json" if self.saved_state_path ``` Reproduced on `07f3bd39` with #1958 applied (so `saved_state_path` survives `__init__`): ``` saved_state_path: my_agent_state.json save() -> agent_workspace/agents/rt-6fd6a53ba3f2/my_agent_state.json load() -> my_agent_state.json.json ``` Two faults in one line: the extension is appended to a name that already has it, and the workspace join is missing, so `load()` looks in the process cwd. `load()` raises `FileNotFoundError` for a file `save()` wrote seconds earlier. ## 2. The workspace directory is random per construction `_get_agent_workspace_dir()` keys the directory on `self.id`, which is regenerated on every `Agent(...)` unless the caller passes `id=`. Two agents with identical configuration: ``` first construction: agent_workspace/agents/rt-6fd6a53ba3f2 second construction: agent_workspace/agents/rt-a3709dfcb989 same dir? False ``` So even with fault 1 fixed, a restarted process cannot find what the previous one saved — the filename is right and the directory is not. This is the part that matters for the `autosave=True` long-running-agent pattern the docs recommend, because that is the only reason to persist state across processes. ## Suggested fix One resolver, called by both, so they cannot drift again: ```python def _resolve_state_file(self, candidate: Optional[str]) -> Optional[str]: """Absolute path of a state file. save() and load() must agree.""" if not candidate: return None if not candidate.endswith(".json"): candidate += ".json" if os.path.isabs(candidate): return candidate return os.path.join(self._get_agent_workspace_dir(), candidate) ``` `load()` keeps its `load_state_path` precedence; only the final path construction is shared. Fault 2 needs a decision rather than a patch, so I have not proposed one: making the workspace directory stable means keying it on something durable (`agent_name`, or `id` defaulted deterministically), and that changes where existing agents write. Worth saying which you want before anyone moves it. Happy to send the fix for fault 1 on its own — it is self-contained and testable offline.
## What is wrong `self.index` appears twice in `swarms/structs/round_robin.py` — set to `0` in `__init__`, and reassigned to `(loop * n) + i` inside the turn loop. Nothing reads it. Selection comes from a bare `for i, current_agent in enumerate(self.agents)`, so the visit order is recomputed from scratch on every call: ``` run() #1 order: [0, 1, 2] run() #2 order: [0, 1, 2] <- restarts; does not resume ``` `run_batch` is `[self.run(task) for task in tasks]`, so **`agents[0]` opens every task in the batch and `agents[N-1]` opens none.** ## Why the opening seat matters `build_collaborative_task` hands every agent the transcript plus: > Review the transcript above and build on the prior speaker's contribution. So the opener sets the framing and everyone after it anchors on that. Giving one agent that position on every task in a batch is the fixed hierarchy round-robin exists to prevent — it has just moved up a level, from within-run to across-runs. Within a single `run()` the schedule is already correct: fixed cyclic order, exactly `max_loops` turns each, correct `prev`/`next` headers at the wrap-around and both boundaries. The defect is only across calls. ## The change Selection becomes `agents[(start + t) % n]` with `start = self.index`, and `run()` advances `index` by one on the way out. The explicit `+ 1` is the part that does the work: `max_loops * n` turns is a whole number of rotations, so a pointer that merely "resumes where it stopped" lands on the same opener forever. With the step, `run_batch(["a", "b", "c"])` on a 3-agent swarm opens with agents 0, 1, 2. Within a single `run()` nothing changes — same order, same turn counts. `prev_name` / `next_name` now derive from the visit order (`(start + i ± 1) % n`, with the first and last turn of the whole run reporting `None`), which replaces the previous first/last special-casing and reads more directly. ## On the `persist_rotation` flag The issue — which I wrote — suggested gating this behind `persist_rotation: bool = False`, on the grounds that some callers may depend on `run()` being reproducible turn-for-turn. That reasoning does not survive checking. `self.conversation` is built in `__init__` and never reset, so `run()` #2 already starts with `run()` #1's entire transcript in context. Consecutive calls are not reproducible today and cannot be made so by pinning the rotation. A flag defaulting to off would leave the defect active for every existing caller in order to preserve a property the class does not have. So this changes the behaviour directly. If you would still rather have the flag, say so and I will add it. ## Verification ``` PYTHONPATH=. pytest tests/structs/test_round_robin_swarm.py -q -p no:randomly 7 passed ``` Confirmed the two behavioural tests fail on unpatched source (stashing only `swarms/`): ``` FAILED test_every_agent_opens_exactly_once_across_a_batch FAILED test_the_rotation_wraps_and_stays_a_full_cycle 2 failed, 5 passed ``` `black==24.2.0 --check` and `ruff==0.2.1 check` clean on both files. ### About the test file `test_run` in that file built real `Agent` objects and called `swarm.run(...)`, so on `master` it fails with `litellm.InternalServerError: Missing credentials` before asserting anything — and its assertion, `result == task`, could not hold in any case since `run()` returns the agent output. Since this PR changes the exact attribute that test looked at (`swarm.index`), I replaced it with offline tests that read the visit order off a recording stub agent. No provider, no network, and they actually describe the schedule. Closes #1864
## What is wrong `AutonomousAgentLoop.run()` appends the handoff prompt straight onto durable agent state: ```python agent_registry = self.agent._get_agent_registry() if agent_registry: handoff_prompt = get_handoffs_prompt( list(agent_registry.values()) ) self.agent.system_prompt += "\n\n" + handoff_prompt ``` `system_prompt` outlives the call; `run()` does not. So every `run()` on an agent with handoffs leaves one more copy of the roster behind. `Agent.__init__` already appends the same prompt for handoffs given at construction, so the count starts at 1 before the loop has run at all. Three runs of one agent: ``` agent.system_prompt.count("**Available Agents:**") -> 4 ``` That is the assertion failure the new test produces against unpatched `master`. The model then reads the same delegation instructions and the same agent list four times over, and the prompt keeps growing for the life of the object — which matters most for exactly the agents this affects: long-lived ones reused across tasks. ## The fix The handoff **tool** block immediately above this already guards against the same thing — it builds `existing_tool_names` and skips any schema already registered. The prompt append was the one part of that block with no equivalent check. This adds it. Deduplicating rather than deleting the append: `__init__` only sees handoffs passed to the constructor, so an agent whose `handoffs` are set afterwards still needs the loop to add the roster. The check keeps that working while making the second and later calls no-ops. ## Verification ``` PYTHONPATH=. pytest tests/agents/test_autonomous_loop.py -q -p no:randomly 33 passed ``` The two new tests live in the existing autonomous-loop suite, which is fully offline — `Agent.call_llm` is the only seam patched, so the real loop, real tool dispatch and real state transitions run. Confirmed the first one fails on unpatched source (stashing only `swarms/`): ``` FAILED TestHandoffPromptIsAppendedOnce::test_running_three_times_does_not_stack_three_rosters where 4 = ….count('**Available Agents:**') 1 failed, 1 passed ``` The second test asserts the roster is still present after a run, so a future "fix" that simply drops the append fails too. `black==24.2.0 --check` and `ruff==0.2.1 check` both clean on the two touched files. Closes #1968
Closes #1968. ## The problem `AutonomousAgentLoop._run_autonomous_loop` appended the handoff prompt to `self.agent.system_prompt` inside per-run setup, so a reused agent accumulated a fresh copy on every `run()`. Measured on `master` @ `07f3bd39`: ``` base system_prompt length : 15359 after run 1/2/3 : [17347, 19335, 21323] grew every run : True ``` **+1,988 characters per run, linearly, with no ceiling.** On a long-lived agent that is context the model pays for on every call, filled with duplicated instructions. Worth noting: the tool append immediately above it *does* guard against duplicates by name (`if tool_name not in existing_tool_names`), so idempotency was considered for the tools and missed for the prompt. ## The fix, and why not the shorter one The loop remembers the block it applied and removes it before applying the current one. A plain "already present" check would have been two lines shorter and wrong: it would pin the **first** registry's text forever, so a handoff target added between runs would never be described to the model. Remove-then-reapply keeps the prompt one copy long *and* lets it change when the registry does. ## Something reviewers should know `Agent.__init__` already applies the handoff prompt once. So on an unchanged registry the correct result is that `run()` leaves `system_prompt` **exactly as it found it** — not that it appends once. That surprised me mid-verification, and it is why the tests assert two things rather than one: the size is stable, *and* the delegation instructions are still present. A fix that stopped the growth by never applying the prompt would look identical on the first assertion and would silently break handoffs. ## Verification **1. The reproducer stops reproducing.** ``` after run 1/2/3 : [15359, 15359, 15359] # was [17347, 19335, 21323] grew every run : False ``` **2. Handoffs still work, and a changed registry still refreshes.** Driving the loop with `handoffs=[Alpha]`, then adding `Beta` between runs: ``` run1 len 15358 mentions Alpha: True mentions Beta: False run2 len 15453 mentions Alpha: True mentions Beta: True <- a stale guard would be False run4 len 15453 <- stable again, no resumed growth ``` The prompt grew by the 95-character delta between the two registries, not by another full copy. **3. The tests are real regression tests.** Against unfixed `master`, **2 of the 5 fail** — `test_prompt_does_not_grow_across_runs` and `test_the_refreshed_prompt_is_still_stable`, exactly the two asserting the growth invariant. The other three pass there, because `master` does apply the prompt; it just stacks copies. I would rather say that than claim five. **Full file**: `tests/agents/test_autonomous_loop.py` goes **31 → 36 passed**, nothing else affected. Lint with the CI-pinned versions (`black==24.2.0`, `ruff==0.2.1`, line-length 70): `black --check` clean on both files, `ruff check .` clean repo-wide. **Not verified here**: no provider call was made. The tests drive the loop to the first `llm_handling` call and stop there — the append happens during setup, before the model is contacted, so the real code path runs offline. What is not covered is the loop's behaviour *after* that point, which this change does not touch.
## Summary The full tool-schema list is written into the conversation as a message attributed to the **agent itself**. It duplicates information already sent through the API's `tools` parameter, and it makes the conversation open with an assistant turn that the assistant never said. ## Location `swarms/structs/agent.py:978-981` ```python self.short_memory.add( role=self.agent_name, content=self.tools_list_dictionary, ) ``` ## Details `tools_list_dictionary` is already passed to the provider as the `tools` parameter, which is the supported and cheaper channel — providers apply their own formatting and, for Anthropic, cache it as part of the stable prefix. Writing it into the conversation as well means: - **Duplicated tokens.** The full JSON schema of every tool is sent twice per request, once as `tools` and once as conversation content. For an agent with a dozen tools this is a large, permanent overhead on every call. - **Wrong attribution.** It is stored under `role=self.agent_name`, so anything mapping conversation roles onto chat roles reads it as an `assistant` turn. A conversation that opens with the assistant reciting its own tool schemas is not a shape any model was trained on. - **It is not a stringly-typed value.** `content` is a `list`, unlike every other entry, so it renders as a Python repr (`[{'type': 'function', ...}]`) in `return_history_as_string()`. Observed on a fresh agent with one tool: ``` role='System' '\nYou are an autonomous agent designed to serve users by auto...' role='P' "[{'type': 'function', 'function': {'name': 'get_weather', 'd..." role='Human' 'task' ``` ## Proposed fix Drop the `short_memory.add(...)` call — the `tools` parameter already carries this. If a record is wanted for debugging or transcripts, log it, or store it under a non-conversational role that prompt construction skips (as `"System"` already is). Worth checking against #1977: with a structured transcript the tool list is genuinely redundant, since tool calls and results now appear in their proper form.
## Summary `Conversation` can only store `(role: str, content: Any)`. It has no representation for an assistant turn carrying `tool_calls`, nor for a tool result keyed by `tool_call_id`. Because the chat-completions API requires exactly that structure, agent loops that want to send a faithful conversation must build and maintain a **second, parallel transcript** alongside `short_memory`. ## Location - `swarms/structs/conversation.py:574` — `add(self, role: str, content, metadata=None, category=None)` - `grep -c tool_call swarms/structs/conversation.py` → **0** - Parallel transcripts: `swarms/agents/autonomous_loop.py` (`self._transcript`) and `swarms/structs/agent.py` (`_transcript_from_memory`) ## Details Both loops now send a structured `messages[]` body. Neither can build it from `short_memory`, because the structure was never stored there — tool calls and results were flattened to prose like: ``` Tool Executor: create_file result: Successfully created file: /tmp/x.txt ``` So each loop keeps a `Transcript` in parallel and mirrors every write into both. That works, but it has real costs: 1. **Two sources of truth that can diverge.** Anything that touches `short_memory` without going through the loop's mirror helpers — `add_memory()`, RAG injection, `Conversation.compact()`, a conversation loaded from disk — updates one and not the other. 2. **Lossy reconstruction.** `Agent._transcript_from_memory()` has to *guess* chat roles from free-form role strings (`"System"`, `"Human"`, the agent name, `"Tool Executor"`), mapping anything that is not the agent to `user`. A restored conversation can never recover the tool structure that was discarded. 3. **Persistence drops the structure.** Saving and reloading an agent keeps the prose rendering, so a resumed run starts from a degraded transcript. 4. **Every new loop reimplements it.** The shared `swarms/structs/transcript.py` reduces the duplication, but the underlying gap is in `Conversation`. ## Proposed fix Teach `Conversation` to store structured turns: - accept `tool_calls` on an assistant message and `tool_call_id` on a tool message (optional fields, so nothing existing breaks) - add `to_messages()` returning a chat-completions-ready body - keep `return_history_as_string()` as the rendering for display, persistence-compat, and the `transforms` path Then `Transcript` becomes a thin view over `Conversation` rather than a parallel store, and `_transcript_from_memory()`'s role-guessing can be deleted. ## Related Follows from #1977 (structured transcript in the auto loop) and the equivalent change on the integer `max_loops` path.
## Summary - add an optional per-run `max_run_tokens` guard for `max_loops="auto"` - estimate the system prompt, structured messages, tool schemas, and responses rather than relying on the removed flattened-history path - stop before a request that cannot fit and return an explicit deterministic budget-exhausted summary without spending tokens on another model call - expose `max_subtask_iterations` and `max_subtask_loops` as validated `Agent` constructor parameters - report cumulative autonomous LLM calls and locally estimated text tokens in final summaries - document all three safety controls in the public autonomous-agent example The remaining estimated allowance is passed as the request `max_tokens` cap. Provider-reported usage and billing remain authoritative: the local estimate deliberately excludes image, audio, cache, and hidden reasoning tokens. Fixes #1976 ## Rebase note This branch is rebuilt on current `master` after #1990. It preserves that PR's structured transcript, mutable-plan, and correctness changes; the budget now measures the structured `messages` payload passed by the current implementation. ## Validation - `pytest tests/agents/test_autonomous_loop.py tests/structs/test_agent.py::TestAutonomousAgentLoop -q -p no:randomly` — 46 passed - `pytest tests/agents/ -q -p no:randomly` — 300 passed, 5 failed - the same exact five tests fail on untouched `upstream/master` (four missing legacy error re-exports and one pre-existing `imgs=None` mock expectation), so the branch adds no failures to that suite - `black . --check --diff` — 980 files unchanged - `ruff check .` — passed - `python -m py_compile swarms/agents/autonomous_loop.py swarms/structs/agent.py tests/agents/test_autonomous_loop.py` — passed - `git diff --check` — passed - changed-file credential-pattern scan — clean ## Disclosure Prepared with AI coding assistance by the Fablgen Agent account. I reviewed the implementation and test results before updating the PR.
## Summary Once an autonomous run starts there is no way to stop it cleanly or redirect it. The only controls are `Ctrl+C` and waiting. ## Location `swarms/agents/autonomous_loop.py` (whole loop); dispatch at `swarms/structs/agent.py:3079` ## Details - `interactive=True` is not compatible with the autonomous loop — the auto path is entered purely on `max_loops == "auto"` and contains no user-input handling. The docstring states the loop is for "`max_loops="auto"` and `interactive=False`". - There is no cancellation token, no `should_continue` callback, and no way to inject a correction between iterations. - `streaming_callback` is output-only. - `Ctrl+C` raises through `_handle_run_error`, losing the run. A long autonomous run that goes off course at subtask 2 of 12 currently has to be killed and restarted from scratch, even though the operator can see the problem in real time. ## Proposal - **Cancellation:** accept a `cancel_event: threading.Event`; check it between iterations and exit into the summary phase (preserving partial work) rather than raising. - **Steering:** accept an optional message queue checked between iterations; anything on it is injected as a user turn before the next LLM call. - **Checkpointing:** persist plan state per subtask so a killed run can resume rather than replan. The `before_tool_call` hook from the permission-model issue provides the natural synchronous interrupt point. ## Impact Makes long unattended runs recoverable instead of all-or-nothing. Priority: P2 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `subtask_done` takes the model's word for it. There is no notion of verifying that a subtask actually achieved anything, and no place in the plan to declare how it would be checked. ## Location - `swarms/structs/autonomous_loop_utils.py:148` — `create_plan` step schema - `swarms/structs/autonomous_loop_utils.py:~225` — `subtask_done` schema - `swarms/agents/autonomous_loop.py:1090` — `_subtask_done_tool` ## Details The current contract is `subtask_done(task_id, summary, success)` — a self-report. The prompt asks the model to "Only call `subtask_done` once the work is ACTUALLY DONE", which is the right instruction and not enforceable on its own. Every reliable agentic loop closes this with an external check: run the tests, re-read the file, execute the script. Making that a first-class field means the model commits to a falsifiable success criterion *at planning time*, before it has any incentive to declare victory. ## Proposal - Add an optional `verification` string to each plan step: a concrete, checkable criterion (`"pytest tests/test_auth.py passes"`, `"report.md exists and is >500 words"`). - Add `verification_result` to `subtask_done`, required when the step declared one — the model must state what it ran and what it observed. - Where the criterion is a command, optionally execute it directly and attach the real exit code rather than trusting the report. - Surface per-subtask verification status in the final summary. ## Related Depends on the mutable-plan work; the verification result is state that lives on the plan. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary When the model returns several tool calls in one response they are executed strictly one at a time, even when they are independent reads. This is pure wall-clock waste. ## Location `swarms/agents/autonomous_loop.py:~620-700` — the `for tool_call in response:` dispatch loop ## Details ```python for tool_call in response: ... result = planning_tool_handlers[function_name](**arguments) ``` The execution prompt explicitly asks the model to batch calls into a single response, so multi-call responses are the expected case, not the exception. Reading four files or running three greps then takes 4x/3x longer than necessary — and each is mostly I/O wait. Note the regular (user-defined) tools path already goes through `tool_struct.execute_function_calls_from_api_response(regular_tool_calls)` as a batch, so the two paths are inconsistent. ## Proposal - Partition the response's tool calls into read-only (`read_file`, `grep`, `list_directory`, `glob`) and mutating (`create_file`, `edit_file`, `run_bash`, `delete_file`, plan/control tools). - Run the read-only group concurrently via a thread pool (the codebase already uses `ContextThreadPoolExecutor`). - Run mutating calls sequentially in the order given, so ordering semantics are preserved where they matter. - Assemble results back in the original call order before appending to the transcript. ## Impact Meaningful latency reduction on the exploration-heavy phases that dominate long runs. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary File discovery is limited to `list_directory`, which is flat, non-recursive, and gitignore-unaware. There is no way to answer "where are the test files" without shelling out. ## Location `swarms/structs/autonomous_loop_utils.py:~820` (`list_directory_tool`) ## Details `list_directory_tool` lists a single directory's immediate entries. To find files by pattern the agent must fall back to `run_bash("find ...")`, which: - runs against a different root than the file tools (see the split-root bug), - returns unbounded output, - may be blocked or mangled by the command blocklist, - and returns paths the file tools then can't resolve. `grep_tool` has an `--include` glob but only as a filter on a content search — it cannot list files by name. ## Proposal Add `glob(pattern, path=None)`: - supports `**` recursion, e.g. `**/*.py`, `src/**/test_*.py` - respects `.gitignore` and skips `.git`, `node_modules`, `__pycache__`, `.venv` by default (with an `include_ignored` escape hatch) - returns paths sorted by modification time (most recently changed first — usually what's relevant) - caps result count with an explicit truncation marker ## Related Fixes the most common reason the agent reaches for `run_bash` today. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary Nothing stops the agent from overwriting a file it has never read. Requiring a prior read in the same session eliminates a whole class of blind-overwrite failures for a few lines of bookkeeping. ## Location `swarms/structs/autonomous_loop_utils.py:~680` (`update_file_tool`), and the proposed `edit_file` ## Details `update_file_tool` checks only that the path exists: ```python if not os.path.exists(full_path): return f"Error: File does not exist at {full_path}. Use create_file to create new files." ``` It then happily replaces the entire contents with whatever the model produced — including when the model is guessing at what the file contained. Combined with whole-file replace being the only edit mode today, this is how agents silently destroy files. ## Proposal - Track paths read during the current run (`agent._read_paths: set[str]`). - `edit_file` / `update_file(mode="replace")` refuse to operate on a path not in that set, with an actionable error: `"Read {path} before editing it."` - Also record the mtime at read time and refuse if the file changed since — catches concurrent modification. - `create_file` and `mode="append"` are exempt. ## Impact Cheap, high-yield safety property. Standard in agentic coding harnesses for exactly this reason. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `read_file` is all-or-nothing and returns bare text with no line numbers, so large files are unusable and file contents cannot be cross-referenced with grep output or tracebacks. ## Location `swarms/structs/autonomous_loop_utils.py:759` and its schema at `:~250` ## Details ```python with open(full_path, "r", encoding="utf-8") as f: content = f.read() return content ``` Two problems: 1. **No pagination.** A 5000-line file is one tool result (see the unbounded-output bug). There is no way to read lines 400-450. 2. **No line numbers.** `grep_tool` already returns `-n` line numbers by default, and stack traces reference line numbers — but `read_file` output has none, so the model cannot connect them. It also cannot describe an edit location precisely. ## Proposal - Add `offset` (1-indexed start line) and `limit` (line count) parameters, with a sensible default cap. - Return `cat -n` style output: right-aligned line number, tab, content. - Include a trailer when truncated: `[showing lines 1-500 of 4211]`. Line-numbered output is a prerequisite for the model reliably targeting `edit_file` at the right place. ## Related Pairs with the `edit_file` and read-before-write proposals. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `run_bash` is gated by a substring blocklist, which is the wrong shape for the problem: it blocks benign commands while failing to stop equivalent dangerous ones. Replace it with an allowlist + sandbox + approval hook. ## Location `swarms/structs/autonomous_loop_utils.py:930-1012` ## Details See the blocklist bug for the specific false positives (`> /dev/null`, substring `sudo`, `printenv`) and false negatives (`find . -delete`, `git clean -xfd`). The structural problem is that a denylist over an infinitely expressive shell language cannot be complete, and every patch to it adds more false positives. There is also no human-in-the-loop path at all: `interactive=True` disables auto mode entirely (`agent.py:3079` only routes to the autonomous loop, and there is no approval callback anywhere in it), so the choice today is fully unattended or not autonomous. ## Proposal Three layers, in order: 1. **Auto-approve allowlist** for read-only commands (`ls`, `cat`, `grep`, `git status`, `git diff`, `pytest`, ...) — matched on the parsed command word, not substrings. 2. **Workspace sandbox** — a configured root; commands run with `cwd` set to it, and writes outside it require approval. (This also fixes the split-root bug.) 3. **`before_tool_call` hook** — a caller-supplied callback receiving `(tool_name, arguments)` and returning allow / deny / modified-args. Everything not auto-approved goes through it. Default policy configurable: `"auto"` (current behaviour), `"prompt"`, `"deny"`. The hook is the piece that unlocks human-in-the-loop, audit logging, and per-deployment policy without further changes to the loop. ## Related Supersedes the interim blocklist fixes. Priority: P0 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary The only way to modify a file is to rewrite it whole (`update_file` with `mode="replace"`) or append to it. There is no targeted edit. This is the biggest capability gap in the tool set for any code-related task. ## Location `swarms/structs/autonomous_loop_utils.py:~680` (`update_file_tool`) and its schema at `:~200` ## Details ```python "mode": {"enum": ["replace", "append"], ...} # replace = overwrite the entire file ``` To change three lines in a 600-line file the model must emit all 600 lines. This causes, in rough order of severity: 1. **Silent content loss** — the model reconstructs the file from memory and drops or subtly alters parts it wasn't focused on. This is the dominant failure mode of whole-file-rewrite agents. 2. **Token cost** — the file is paid for on output as well as input, on every edit. 3. **Truncation** — files beyond the output limit simply cannot be edited. 4. **Unreviewable diffs** — every edit touches the whole file. ## Proposal Add `edit_file(file_path, old_string, new_string, replace_all=False)`: - `old_string` must match the file **exactly**, including indentation. - Fail with a clear error if it matches zero times, or more than once when `replace_all=False` — the ambiguity error should tell the model to include more surrounding context. - Return the affected line range so the model can verify. Pair with the read-before-write invariant and line-numbered reads (separate issues) — the three are designed to work together. ## Impact This is the difference between an agent that can maintain a codebase and one that can only create files. Priority: P0 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary The autonomous loop's only limits are iteration counts, not token or cost budgets. Combined with re-sending the full history each call, a single `agent.run()` can issue on the order of 2000 LLM calls with a monotonically growing prompt. ## Location - `swarms/structs/autonomous_loop_utils.py:46-47` — `MAX_SUBTASK_ITERATIONS = 100`, `MAX_SUBTASK_LOOPS = 20` - `swarms/agents/autonomous_loop.py:564` — every call re-serializes the entire history ## Details The outer `while not self._all_subtasks_complete()` allows up to 100 iterations; each selects a subtask whose inner loop runs up to 20 LLM calls. Nothing caps the product. Every one of those calls sends `short_memory.return_history_as_string()` — the whole conversation, growing with each tool result — and `ContextCompressor` does not run in this mode. There is no token accounting, no spend ceiling, and no way for a caller to bound a run other than editing module constants. For an unattended agent on a metered API this is the difference between a $2 run and a $2000 one. ## Proposed fix - Add a token/cost budget to `Agent` (e.g. `max_run_tokens`) checked before each LLM call; on exhaustion, stop cleanly and go to the summary phase with an explicit "budget exhausted" note rather than hard-failing. - Make `MAX_SUBTASK_ITERATIONS` / `MAX_SUBTASK_LOOPS` constructor parameters rather than module constants. - Track and report cumulative tokens in the final summary. ## Impact Unbounded, unpredictable spend on unattended runs. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary The "Current date and time" line in the autonomous agent system prompt is evaluated once, when the module is imported, so every agent in a long-lived process is told the wrong time. ## Location `swarms/prompts/autonomous_agent_prompt.py:11-26` ## Details ```python def get_time() -> str: now = datetime.now().astimezone() return f"Current date and time: {now.strftime('%A, %B %d, %Y %H:%M %Z')}\n" AUTONOMOUS_AGENT_SYSTEM_PROMPT = f""" ... Time: {get_time()} """ ``` `AUTONOMOUS_AGENT_SYSTEM_PROMPT` is a module-level f-string, so `get_time()` runs at import. In a server or notebook the value is the process start time — hours or days stale. Agents doing anything date-relative ("latest", "this quarter", "yesterday's logs") get bad grounding, and `get_autonomous_agent_prompt()` returning a constant hides it. ## Proposed fix Make `get_autonomous_agent_prompt()` build the string at call time, interpolating `get_time()` then. The function already exists as the public accessor (`agent.py:532` uses it), so this is a contained change. ## Impact Stale time grounding in every long-running process. Priority: P2 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary When a subtask exhausts its iteration budget it is logged and skipped, but the summary phase unconditionally announces that everything completed and the final summary has no signal that work was dropped. ## Location `swarms/agents/autonomous_loop.py:933-950` ## Details ```python if not subtask_done: if self.agent.print_on: formatter.print_panel( f"Subtask {subtask_id} not completed after {max_subtask_loops} iterations", title="Subtask Timeout", ) ... # falls through to: formatter.print_panel( "All subtasks completed. Generating final summary...", # line 947 title="Autonomous Loop: Summary Phase", ) ``` The abandoned subtask's status stays `"pending"`, so: - `_all_subtasks_complete()` returns `False`, but the outer `while` has already exited via the iteration cap or the `_get_next_executable_subtask() is None` branch. - The summary panel claims completion regardless. - `_generate_final_summary` asks the model to call `complete_task` with a `success` flag, but nothing tells it a subtask was abandoned — it will typically report success. The same applies to the `total_iterations > max_subtask_iterations` break at line 495 and the "no executable subtasks found" break at line 520. ## Proposed fix Track abandoned/blocked subtasks explicitly (a `timed_out` / `blocked` status), make the summary panel conditional on `_all_subtasks_complete()`, and inject the list of unfinished subtasks into the summary prompt so `complete_task` reports `success=False` with the gap named. ## Impact Partial failures are reported to the user as complete successes. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `create_sub_agent` builds an `Agent` with no `tools`, no workspace context, and `max_loops=1`. The result is a single stateless LLM call that cannot read files, run commands, or take any action — so `assign_task` can only ever return text. ## Location `swarms/structs/autonomous_loop_utils.py:1240-1255` ## Details ```python sub_agent = Agent( id=agent_id, agent_name=agent_name, agent_description=agent_description, system_prompt=system_prompt, model_name=agent.model_name, max_loops=1, print_on=True, # Reduce noise from sub-agents ) ``` - No `tools=` / `tools_list_dictionary=` — the sub-agent inherits none of the parent's capabilities, and none of the autonomous loop's file/bash/grep tools. - `max_loops=1` — one shot, no tool-use loop even if it had tools. - The comment says "Reduce noise from sub-agents" while setting `print_on=True`, which does the opposite. - No workspace, `context_length`, `temperature`, or `verbose` inheritance from the parent. The delegation story in the prompt ("create specialized sub-agents for delegation") is therefore not backed by the implementation: a sub-agent is strictly less capable than the parent asking it a question directly. ## Proposed fix - Pass the parent's tool set (and optionally the autonomous tool set) to sub-agents. - Give them `max_loops` > 1 so they can actually use those tools, or a `max_loops` parameter on `create_sub_agent`. - Fix `print_on=True` to match its comment, or fix the comment. - Inherit workspace/context settings from the parent. ## Impact Delegation costs an extra LLM round-trip and returns less than doing the work inline. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `_generate_final_summary` returns a raw string on one path and a formatted history object on the others, so the return type of `agent.run()` in auto mode depends on whether the model happened to call `complete_task`. ## Location `swarms/structs/agent.py:1897-2000` ## Details ```python if isinstance(response, list): for tool_call in response: if ... == "complete_task": result = self._complete_task_tool(**arguments) ... return result # <- raw str # fallback path return history_output_formatter(self.short_memory, type=self.output_type) # <- output_type-shaped except Exception: return history_output_formatter(self.short_memory, type=self.output_type) # <- same ``` `self.output_type` (default `"str-all-except-first"`) is honoured on two of the three paths and ignored on the third. Callers that expect a list or dict per `output_type` get a bare string whenever `complete_task` was called — the *success* path. ## Proposed fix Route every path through `history_output_formatter` after adding the completion summary to memory, so `output_type` is always respected. ## Impact Downstream consumers (swarm structures, `SwarmRouter`) can receive a shape they did not ask for, on the common path. Priority: P2 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `grep_tool` caps its output at 64 KB, but `run_bash_tool` and `read_file_tool` return whatever they get. A single large file or verbose command floods the conversation. ## Location - `swarms/structs/autonomous_loop_utils.py:759` — `read_file_tool` returns `f.read()` whole - `swarms/structs/autonomous_loop_utils.py:1014` — `run_bash_tool` returns full stdout + stderr - compare `_GREP_MAX_BYTES = 65536` at line ~1100, which does cap ## Details `read_file_tool` has no `offset`/`limit`, so reading a 2 MB log is a single tool result of 2 MB. `run_bash_tool` likewise returns the complete output of `cat`, `find /`, `npm install`, or a failing test suite. This compounds with two other issues: the loop re-serializes the *entire* history into the prompt on every iteration, and `ContextCompressor` never runs in auto mode. One oversized read is therefore resent on every subsequent LLM call for the rest of the run. ## Proposed fix - Cap both tools (a shared `_MAX_TOOL_OUTPUT_BYTES`), truncating with an explicit marker such as `... [truncated 1.9 MB, showing first 64 KB]` so the model knows output was cut and can narrow its query. - Give `read_file` `offset`/`limit` parameters (see the separate enhancement) so large files are navigable rather than all-or-nothing. ## Impact A single unlucky read can exhaust the context window and end the run. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `run_bash` executes in the process working directory while every file tool resolves relative paths against the agent workspace. The two views of the filesystem disagree. ## Location - `swarms/structs/autonomous_loop_utils.py:1051` — `cwd=None, # use process current working directory` - `create_file_tool:640`, `update_file_tool:~700`, `read_file_tool:~775`, `list_directory_tool:~840`, `grep_tool:~1130` — all use `agent._get_agent_workspace_dir()` ## Details ```python # run_bash_tool result = subprocess.run(command, shell=True, ..., cwd=None) # every file tool if not os.path.isabs(file_path): workspace_dir = agent._get_agent_workspace_dir() full_path = os.path.join(workspace_dir, file_path) ``` So `create_file("notes.md", ...)` writes to `{workspace}/agents/{name}/notes.md`, but `run_bash("ls")` lists the directory the user launched the script from, and `run_bash("cat notes.md")` fails. `grep` searches the workspace; `run_bash("grep ...")` searches the project. The model has no way to tell these apart and will conclude files it just wrote do not exist. The `run_bash` comment says this is deliberate ("so commands like `ls -la` and `python script.py` see the project directory, not the agent workspace") — which is a reasonable choice, but then the file tools should follow it. ## Proposed fix Pick one root and use it everywhere, exposed as a single configurable attribute (e.g. `agent.working_dir`, defaulting to `os.getcwd()`). Pass it as `cwd=` to `subprocess.run` and as the join base for the file tools. ## Impact Files written by the agent appear missing to its own shell commands; searches silently cover the wrong tree. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `_BASH_BLOCKLIST` uses substring matching, which both blocks common harmless commands and fails to stop equivalent dangerous ones. ## Location `swarms/structs/autonomous_loop_utils.py:930-1012` ## Details **False positives — benign commands are blocked:** | Pattern | Blocks | |---|---| | `("> /dev/null",)` (line 952) | *any* command redirecting to `/dev/null` — `make test > /dev/null 2>&1`, `command -v foo > /dev/null` | | `("sudo",)` (line 971) | any command containing the substring anywhere, e.g. `grep -r sudo config/`, `cat sudoers.md` | | `("printenv",)`, `("env |",)` | ordinary environment inspection | | `("/etc/passwd",)` | reading a file that is world-readable by design | **False negatives — equivalents pass:** - `("rm", "-rf")` is blocked, but `find . -delete`, `git clean -xfd`, and `python -c "shutil.rmtree(...)"` are not. - Any blocked command can be reached through a script file, `xargs`, or an alias. **Unrelated hard limit:** `_BASH_MAX_LENGTH = 512` (line 997) rejects any command over 512 characters, which makes heredocs and multi-step shell one-liners impossible. ## Proposed fix See the permission-model issue. A blocklist is the wrong shape for this: replace it with an auto-approve allowlist for read-only commands, a sandboxed working root, and an approval hook for everything else. If the blocklist has to stay in the interim, at minimum drop `> /dev/null`, anchor `sudo` to the start of a command word, and raise the length cap. ## Impact Agents fail on routine commands, and the security guarantee the list implies does not hold. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary The autonomous loop appends the handoff prompt to the agent's `system_prompt` in place, so it is re-appended on every `run()` call. Long-lived agents accumulate duplicate copies until the context fills. ## Location `swarms/agents/autonomous_loop.py:224-231` ## Details ```python agent_registry = self.agent._get_agent_registry() if agent_registry: handoff_prompt = get_handoffs_prompt(list(agent_registry.values())) self.agent.system_prompt += "\n\n" + handoff_prompt ``` This mutates persistent agent state from inside a per-run code path. Calling `agent.run(...)` three times on an agent with handoffs yields three concatenated copies of the handoff prompt. There is no guard checking whether it was already appended. Note `agent.py:532` does the same thing for `get_autonomous_agent_prompt()`, but that one is in `__init__` and so runs exactly once — this one is per-run. ## Proposed fix Build the effective system prompt per run without mutating `self.agent.system_prompt`, or guard with a flag / idempotent check. ## Impact Unbounded prompt growth and duplicated instructions for reused agent instances. Priority: P1 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `priority` is collected in the plan schema, stored, printed in the plan panel, and documented as affecting execution order — but the scheduler ignores it entirely. ## Location `swarms/agents/autonomous_loop.py:1224-1248` (`_get_next_executable_subtask`) ## Details The docstring of `_create_plan_tool` states: > **Execution Order:** Subtasks are executed based on: 1. Dependencies ... 2. Priority: Higher priority tasks are preferred when multiple are available 3. Creation order: Used as tiebreaker The implementation returns the first `pending` subtask in list order whose dependencies are satisfied. There is no priority comparison anywhere in the file. The system prompt also tells the model "Critical priority tasks are foundational and must be completed first", which the runtime does not honour. ## Proposed fix Either sort eligible subtasks by `critical > high > medium > low` before returning, or remove the field from the schema, the prompt, and the docstring. The former is a ~5 line change and matches what the model is told. ## Impact Documentation, prompt, and behaviour disagree; a model that carefully assigns priorities gets nothing for it. Priority: P2 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Summary `ContextCompressor.maybe_compress()` is never called during an autonomous (`max_loops="auto"`) run, so conversation history grows unbounded until the model hits its context limit. ## Location - `swarms/structs/agent.py:3079` — auto-mode dispatch - `swarms/structs/agent.py:1386` — the only `maybe_compress` call site ## Details `Agent.run()` branches on `max_loops == "auto"` and calls `_run_autonomous_loop(...)` directly. The `maybe_compress` call lives inside `_run()`'s `while` loop, which the auto path never enters: ```python # agent.py:3079 if self.max_loops == "auto": output = self._run_autonomous_loop(...) # <- returns from here elif n > 1: ... else: output = self._run(...) # <- maybe_compress lives in here ``` The compressor is still constructed in `__init__` (`agent.py:543`) when `context_compression=True`, so it looks wired up but is dead in exactly the mode that needs it most: the auto loop re-sends the whole history on every iteration (see the transcript issue) and can run for hundreds of iterations. `CLAUDE.md` documents the opposite ("`ContextCompressor` fires automatically when token usage crosses 90% of `context_length`"), and warns against disabling it "on very long autonomous sessions" — which is currently the only mode where it does nothing. ## Proposed fix Call `self.agent._context_compressor.maybe_compress(self.agent)` at the top of each subtask iteration in `AutonomousAgentLoop._run_autonomous_loop`, guarded on `is not None`. ## Impact Long autonomous runs fail with a context-length error instead of compacting. Priority: P0 --- Part of a review of the `max_loops="auto"` autonomous loop.
## Changes - add `persist_rotation` (default `False`) and drive scheduling from `(index + turn) % N` - advance `index` by one after each `run()` when the flag is set - document the flag in the class docstring ## Tests - default off: two consecutive `run()` calls produce the same visit order - `persist_rotation=True`: `run_batch` over N tasks on N agents gives each agent the opening turn once - within-run turn counts unchanged Closes #1864
Fixes #1835 ## Summary - Removed 23 unreachable legacy prompt modules from swarms/prompts. - Verified there are no remaining references outside swarms/prompts. - Kept the change limited to the cleanup requested by the issue. ## Verification - py -3.12 -c "import swarms" - py -3.12 -m compileall -q swarms - py -3.12 -m pytest -q tests/utils/test_any_to_str.py tests/utils/test_formatter.py (11 passed) - py -3.12 -m pytest --collect-only -q (1846 tests collected) The full suite was not completed because the current checkout has unrelated missing fixtures and tests that require external model credentials.
## Problem `__init__` accepts `saved_state_path`, assigns it, and twelve lines later overwrites it unconditionally: ```python self.saved_state_path = saved_state_path # line 418 ... self.saved_state_path = ( # line 430 f"{generate_api_key(prefix='agent-')}_state.json" ) ``` ``` asked for 'my_agent_state.json' -> agent-YEkRWEc0j03dYmtPZFRKAO6HBLMreYyV_state.json ``` `save()` reads that attribute: ```python resolved_path = file_path or self.saved_state_path or f"{self.agent_name}_state.json" ``` so an agent configured with a specific state file autosaves somewhere else entirely, and every construction generates a fresh name, leaving another orphan `agent-<random>_state.json` in the workspace on each run. Same shape as the `max_tokens` overwrite in #1957; this is a different attribute twenty lines up, so it is a separate fix. ## Fix Generate only when the caller supplied nothing. ``` explicit -> my_agent_state.json unset -> agent-6CzeWOuLOS1u… ``` ## Scope — what this does *not* fix An earlier version of this description claimed the save/load round trip works after this change. It does not, and @kyegomez was right to call that out. Two further defects sit in `load()` and are **pre-existing and independent of this diff**: - `load()` (`agent.py:2452`) builds `f"{self.saved_state_path}.json"` unconditionally, so a path that already ends in `.json` becomes `my_agent_state.json.json`; and it never joins the agent workspace directory, while `save()` does. So `save()` writes `agent_workspace/agents/<agent>/my_agent_state.json` and `load()` looks for `my_agent_state.json.json` in the process cwd. - `_get_agent_workspace_dir()` keys the directory on `self.id`, which is random per construction unless the caller passes `id=`, so a restarted agent lands in a new directory regardless of the filename. Both are filed separately as #1994 rather than folded in here — they are a different bug (path construction), they need `save()` and `load()` to share one resolver, and this PR is a one-line guard that stands on its own. What this PR fixes is exactly: the attribute the caller passed survives `__init__`, so `save()` writes where they asked. ## Tests `TestSavedStatePath` in `tests/structs/test_agent.py`: explicit path honoured, unset still gets a generated name in the existing format, and two agents without a path still get distinct names — that last one is why the generated fallback cannot simply be keyed on `agent_name`. The first fails on master: ``` FAILED tests/structs/test_agent.py::TestSavedStatePath::test_explicit_path_is_honoured ``` Rebased on `07f3bd39`. Failure set in `tests/structs/test_agent.py` is byte-identical to master (27 pre-existing failures/errors, diffed), plus the three new tests passing. `black==24.2.0 --check` clean. The dead commented-out line above the assignment (an older `agent_name`-prefixed variant) is removed with it.
## Description Fixes `SocialAlgorithms.remove_agent()` to search the agent list by `agent.agent_name`, remove the matching agent while preserving remaining order, and raise the existing `AgentNotFoundError` when no match exists. ## Issue Fixes #1953 ## Validation - `pytest tests/structs/test_social_algorithms.py -q -vv --tb=short -ra` -> `4 passed` - Black passed - Ruff passed - `git diff --check` passed Dependencies: none
## Description Tightens `MajorityVoting.reliability_check()` validation so empty agent lists and non-positive `max_loops` values are rejected before voting begins. Non-positive loop counts produce no voting iterations, so they are treated as invalid configuration. ## Issue Fixes #1952 ## Validation - `pytest tests/structs/test_majority_voting.py -q --tb=short -ra` -> `6 passed` - Black passed - Ruff passed - `git diff --check` passed Dependencies: none
## Description Fixes `AgentRearrange.concurrent_run()` so each task uses the existing `_clone_for_task()` path, giving each task an isolated orchestrator conversation, and rejects mismatched task/image lists before any work is scheduled. Results remain in input order. This reuses the same clone helper already used by `batch_run()`. The helper always creates a fresh `Conversation` and deep-copies agents where possible, with its existing fallback behavior unchanged. ## Issue Fixes #1951 ## Validation - `pytest tests/structs/test_agent_rearrange.py::TestConcurrentRunIsolation -q` -> `3 passed` - full `test_agent_rearrange.py` run shows no new failures relative to upstream (`57 passed, 11 failed`) - Black passed - Ruff passed - `git diff --check` passed ## Scope This PR does not change the existing `*args` API behavior. That separate behavior is being addressed by #1891. Dependencies: none
`SocialAlgorithms.remove_agent(agent_name)` currently executes: del self.agents[agent_name] but `self.agents` is a list, so passing an agent name raises `TypeError` instead of removing the matching agent or raising the method's documented `AgentNotFoundError`. Expected behavior: - find the matching agent by `agent.agent_name`; - remove that agent while preserving remaining order; - raise `AgentNotFoundError` when no matching name exists. I have a focused fix with offline regression tests.
`MajorityVoting.reliability_check()` currently rejects `agents=None` but accepts `agents=[]`. It also accepts `max_loops <= 0`, which results in no voting iterations. Expected behavior: - reject missing or empty agent lists; - reject non-positive `max_loops` values before execution. I have a small validation fix with regression coverage for empty agents and negative loop counts.
`AgentRearrange.concurrent_run()` currently runs all tasks through the same orchestrator instance, so concurrent tasks share the parent conversation. It also uses `zip(tasks, imgs)` without validating matching lengths, which silently drops work when fewer images than tasks are supplied. `batch_run()` already uses `_clone_for_task()` to give each task a fresh orchestrator conversation and validates task/image lengths. Expected behavior: - each concurrent task uses the existing per-task clone path; - the parent conversation is not mutated by concurrent tasks; - task/image length mismatches fail before work is scheduled; - result order still follows task order. I have a focused fix with offline regression coverage for these cases.
## Problem `write_autoswarm_file` used `if swarm_arch.get("max_loops")`, so `True` became `1` (`bool` ⊂ `int`) and `0`/`False` were silently dropped. ## Changes - check `"max_loops" in swarm_arch` (presence, not truthiness) - reject `bool` before `int()` ## Tests - `True`/`False` raise; `0` is emitted; `"3"` coerces; `"abc"` raises; missing key omits the kwarg Closes #1947
## What changed Fixes #1926. `Conversation.add()` accepted a `metadata` argument but silently discarded it because it was not forwarded to `add_in_memory()`. This change: - Adds optional `metadata` support to `add_in_memory()` - Forwards metadata from `Conversation.add()` - Persists metadata on the stored message - Preserves existing category behavior - Adds regression tests for metadata storage and serialization ## Tests - `tests/structs/test_conversation.py` - 55 tests passing ```bash .venv/bin/pytest tests/structs/test_conversation.py -q ``` Result: `55 passed` ## CI Note The failing `Test Main Features`, `Python package`, and `Pyre` checks appear to be pre-existing repository CI issues and are also present on the upstream master branch. The targeted Conversation test suite passes locally: `55 passed` Lint/formatting checks are also passing.
Follow-up to #1538 (closed as superseded by #1722), preserving a case the merged validation does not cover. ## Location `swarms/agents/auto_generate_swarm_config.py:346-353` ```python if swarm_arch.get("max_loops"): try: max_loops_val = int(swarm_arch["max_loops"]) except (ValueError, TypeError): raise ValueError( f"swarm_architecture.max_loops must be an integer, got ..." ) router_lines.append(f" max_loops={max_loops_val},") ``` ## Two gaps **1. Booleans pass validation and are silently coerced.** `bool` is a subclass of `int`, so `int(True) == 1` never raises. A config with `max_loops: true` produces generated Python containing `max_loops=1` with no warning — a typo becomes a silent, plausible-looking value. ```python >>> isinstance(True, int) True >>> int(True) 1 ``` **2. Falsy values skip validation entirely.** The `if swarm_arch.get("max_loops"):` guard is falsy for `0` and `False`, so `max_loops: 0` and `max_loops: false` are dropped from the generated router call without validation or notice, rather than being rejected. Note `SequentialWorkflow.reliability_check` already treats `max_loops == 0` as an error, so the two paths disagree. ## Suggested fix Reject `bool` explicitly, and switch the guard from truthiness to presence: ```python if "max_loops" in swarm_arch: raw = swarm_arch["max_loops"] if isinstance(raw, bool): raise ValueError( "swarm_architecture.max_loops must be an integer, got bool: " f"{raw!r}" ) try: max_loops_val = int(raw) except (ValueError, TypeError): raise ValueError( "swarm_architecture.max_loops must be an integer, got " f"{type(raw).__name__}: {raw!r}" ) router_lines.append(f" max_loops={max_loops_val},") ``` Credit to @shaun0927, whose #1538 included the bool guard before #1722 merged without it.
## Problem `base_model_to_openai_function` emitted the legacy `{"function_call", "functions"}` envelope, and `base_model_to_dict` immediately unpacked `functions[0]` and rewrapped it as `{"type": "function", "function": ...}`. Same peel happened again in `multi_base_model_to_openai_function`. ## Changes - `base_model_to_openai_function` now returns `{"type": "function", "function": {"name", "description", "parameters"}}` directly - `base_model_to_dict` is a thin wrapper around that (validation only) - `multi_base_model_to_openai_function` returns a `list` of those schemas (was a `{function_call, functions}` dict — no in-tree callers) - remove dead `*args`/`**kwargs` from `base_model_to_dict` (forwarding always raised; `B026`) ## Tests - updated name assertion for the modern shape - added `test_no_legacy_envelope_round_trip` (both paths return the same dict, no envelope keys) - updated `test_base_model_to_dict` for the modern shape Closes #1848
## Summary `Agent.run` previously swallowed LLM failures: the retry loop caught bare `Exception`, and after exhausting retries it returned the raw conversation transcript **as if it were the answer**. This PR makes it honest — matching the docstring contract. ## Changes - `swarms/structs/agent.py` — 2 hunks, 15 lines: - `_run` retry loop: remove bare `Exception` from the except tuple (only `BadRequestError`, `InternalServerError`, `AuthenticationError` are retried; other errors propagate immediately). - `if not success:` block: `raise AgentLLMError(...)` with the attempt count instead of `logger.error(...)` + `break` (the transcript-return lets callers act on garbage output with no signal the model never responded). - `run()` fallback path: same except-tuple tightening (remove `Exception`). - `tests/telemetry/test_telemetry_multi_agent_core.py` — the `FakeLLM` now raises a realistic `BadRequestError`, and the per-architecture error tests assert the new honest behavior (`AgentLLMError` propagation + `Agent.llm_error` span, instead of the old always-swallowed `completed`/`OK`). - `tests/structs/test_agent_run_errors.py` — new: `TestFailureHonesty` (3 tests: raise after retries, message reports attempt count, hierarchy). ## Why this PR is small Split from #1931 (21 files → 3 PRs) per maintainer feedback. The other two are: - #1939 — `fix(agent): re-export error classes from swarms.structs.agent` (1 file, fixes 4 failing marketplace tests) — independent. - #1940 — `test: skip live-LLM tests when no API key is set` (18 files, 451 insertions, no behavior change) — **should merge before this one** so the ~90 live-LLM tests stay green (they call `agent.run()` without keys and would all fail once this raise lands). ## Verification - New tests: 3/3 pass; telemetry suite: 28 passed / 157 passed across `tests/telemetry/`. - Full `tests/structs/` + `tests/telemetry/` vs baseline: zero new failures outside the expected skips (markers are in #1940 — this PR intentionally does NOT include them so the diff stays reviewable).
Closes #1831. Deletes `swarms/structs/various_alt_swarms.py`. Pure deletion, no call-site changes. Rebased onto `master` after 1063bbc removed `BaseSwarm` and the four swarm classes from this file, so the diff is now the remaining 240 lines rather than the original 526. ## What is left in the file `OneToOne`, `Broadcast` and `OneToThree`. ## Verification against master at f8e3fff **Zero references.** No hit for the module name or for any of the three class names across `swarms/` and `tests/`, and GitHub code search over the repository returns nothing. The module is still absent from `swarms/structs/__init__.py`, so it is unreachable through the public API. The single grep hit for `Broadcast` outside the file is the string `"Broadcast this message"` at `tests/structs/test_swarm_architectures.py:139`, inside a test that exercises the lowercase `broadcast()` function from `swarming_architectures.py`. It is not a reference to this class. **It duplicates live code.** `swarms/structs/swarming_architectures.py` still provides `one_to_one` at line 251 and `broadcast` at line 307. That module is exported from `swarms/structs/__init__.py` and covered by tests. **The `run()` methods were broken.** Each builds a `responses = []` accumulator, appends to it in the loop, then returns `self._format_return()` and discards the list. ## After the change `swarms/structs/__init__.py` parses cleanly and a fresh grep over the repository returns no reference to the deleted module or to any of its classes.
## Summary Pure test hygiene — no behavior change (451 additions, 0 deletions). Tests that call `agent.run()` against a real model can only pass with an API key, so they are now marked with a `requires_llm` skipif marker (the same convention already used in `tests/telemetry/test_telemetry.py`) and are skipped when no key is set. ## Why Upstream CI (`tests.yml`) runs `pytest tests/` without API keys. These ~90 live-LLM tests either fail or pass vacuously in that environment. With `Agent.run` about to become honest about LLM failures (it will raise `AgentLLMError` after retry exhaustion instead of returning the raw transcript — see the follow-up PR), every one of these tests would fail without a key. This PR lands the skips first so the follow-up behavior change keeps CI green. ## Scope 18 files, each gets the same ~10-line marker block plus `@requires_llm` decorators on the live-LLM tests: - `tests/structs/` (15): test_advisor_swarm, test_agent, test_agent_loader, test_agent_rearrange, test_async_subagent, test_deep_discussion, test_i_agent, test_llm_council, test_majority_voting, test_multi_agent_debate, test_planner_generator_evaluator, test_round_robin_swarm, test_sequential_workflow, test_swarm_architectures, test_swarm_router - `tests/` (3): test_main_features, test_multi_provider, test_streaming_timing ## Verification - Zero new failures vs `master` on the touched files (24 pre-existing keyless failures unchanged); 1 pre-existing failure (`test_sync_run_stream`) now skips. - Verified with the same keyless environment CI uses.
## Summary Adds an `output_schema` parameter to `Agent` for validated structured output: ```python from pydantic import BaseModel from swarms import Agent class WeatherReport(BaseModel): city: str temperature_c: float condition: str agent = Agent(model_name="gpt-4o", output_schema=WeatherReport) result = agent.run("What is the weather in Paris today?") # result is a validated WeatherReport instance ``` ## What it does 1. **Threads the schema to the provider** — `LLMManager.build` passes the model as `response_format` (the same pattern already used by `ModelRouter`, `MultiAgentRouter`, `SkillOrchestra` and `AutoSwarmBuilder`; the core `Agent` was the only structure missing it). 2. **Validates every response** — `_validate_structured_output` normalizes the response (JSON string / dict / BaseModel) and runs `model_validate`. 3. **Retries on schema mismatch** — a `ValidationError` raised inside the existing retry loop is retried like any other LLM failure; after `retry_attempts` are exhausted `run()` returns `None` rather than an unvalidated payload. 4. **Returns the validated model** — `run()` returns the model instance instead of the formatted conversation history. 5. **Keeps memory clean** — the validated JSON (`model_dump_json`) is stored in conversation memory, not the pydantic repr. 6. **Fails fast** — non-Pydantic schemas raise `ValueError` at construction. Accepts a model class or an instance. The autonomous loop (`max_loops="auto"`) is unaffected. ## Why `output_type="basemodel"` is declared in `OutputType` (`swarms/utils/output_types.py`) but was never handled by `history_output_formatter` — it raises `ValueError` at runtime. This PR provides the working path for structured output at the `Agent` level, consistent with the rest of the framework. ## Tests `tests/structs/test_agent_output_schema.py` — 8 tests, network-free (FakeLLM pattern used across the telemetry suite): response_format threading, valid/invalid/retry/exhaustion paths, model-instance schemas, memory JSON storage, non-Pydantic rejection, and unchanged default behavior. Example: `examples/single_agent/capabilities/structured_output/structured_output_example.py`. Verified: full `tests/structs/` + `tests/telemetry/` suites show zero new failures vs `master` (pre-existing environment failures unchanged).
## Summary `run_bash_tool` previously ran `subprocess.run(command, shell=True)`, handing the command string to `/bin/sh`. Shell metacharacters were live, so a prompt like `echo hi > /dev/sda` or `|sh` could execute arbitrary commands — the tool was effectively a remote shell for any agent with tool access. ## Changes - Commands are parsed with `shlex.split` and executed as argv with `shell=False`: metacharacters become inert literal tokens (a redirection attempt fails to exec instead of writing to disk). - The dangerous-command blocklist is checked **both** on the raw string and on the parsed token list, closing two bypasses of the old substring check: - `r""m -rf /` (quoted-concat hides the `rm` substring) - `rm -r -f /` (split flags evade the `rm -rf` substring) - New blocklist entries: `rm -r -f`, `rm --recursive --force`, `rm -rf /`, `chmod 777`. - Rejected commands are logged and recorded in agent short memory as `Blocked (security): ...`; unparseable input (unbalanced quotes, NUL bytes) is rejected without execution. ## Tests - New `tests/structs/test_autonomous_loop_utils.py` (20 tests): raw-string and argv-level blocklist coverage, quoted-concat and split-flag bypasses, NUL/empty argv rejection, shell-metacharacter inertness (`>/dev/sda`, `|sh` fail to exec), benign command execution, and short-memory recording of blocked commands.
Found while verifying the persistence fix in #1925 (same module, separate defect). `Conversation.add` accepts `metadata`, documents it, and drops it: ```python def add(self, role, content, metadata: Optional[dict] = None, category: Optional[str] = None): """... metadata (Optional[dict]): Optional metadata for the message. """ result = self.add_in_memory(role=role, content=content, category=category) # metadata not passed ``` `add_in_memory` has no `metadata` parameter at all, so there is nowhere for it to go. Anything a caller attaches is silently lost — no warning, no error. The fix is two lines mirroring the `category` handling immediately above it, which is exactly the same shape: ```python # add_in_memory(..., metadata: Optional[dict] = None) if category: message["category"] = category if metadata: message["metadata"] = metadata ``` plus `metadata=metadata` on the `add_in_memory` call in `add`. I have this implemented and verified locally: ``` stored metadata : {'trace_id': 'abc123', 'cost': 0.01} category still works : output no key when unset : ['content', 'role'] <- no empty metadata key added to_dict preserves it : {'trace_id': 'abc123', 'cost': 0.01} survives save/load : {'trace_id': 'abc123'} ``` Two reasons it is an issue rather than a PR right now: 1. It is a different defect from #1925 and I did not want to widen that diff — it is the persistence round-trip, this is a dropped argument. 2. There is a product question in it: **should per-message metadata exist at all?** Nothing in the repo passes `metadata=` to `add`, and nothing reads `message["metadata"]` (the `metadata` keys in `load_from_json`/`load_from_yaml` are conversation-level settings, not per-message). So the alternative is deleting the parameter, the way I proposed deleting the never-read `output_type` in #1922. My preference is to implement it — it costs two lines, it is genuinely useful for tracing a message back to a run or a cost, and unlike `output_type` it invents no semantics because the value is an opaque dict. But if you would rather the signature shrink, deleting it is equally defensible and I will send that instead. Happy to open whichever you prefer; the implementing patch is ready to push.
A tool that fails is reported as an LLM generation failure and recovered from by re-running the model. The tool call and the LLM call share one `try` block, and its handler assumes everything inside it came from the provider. ## Root cause `swarms/structs/agent.py:1541` executes tools inside `try@1436-1592`: ```python # Check and execute callable tools if exists(self.tools): self.tool_execution_retry( response, loop_count ) ``` The handler for that block, `swarms/structs/agent.py:1566-1592`: ```python except ( BadRequestError, InternalServerError, AuthenticationError, Exception, ) as e: # Track the LLM/generation error via telemetry — the # retry loop swallows it, so capture_run never sees it. capture_error( e, self, name="Agent.llm_error", loop=loop_count, ) ... logger.error( f"Attempt {attempt+1}/{self.retry_attempts}: Error generating response in loop {loop_count} ..." ) attempt += 1 ``` Three consequences: 1. **Telemetry is wrong.** A tool failure is captured as `Agent.llm_error`. Anyone reading error rates cannot distinguish a broken tool from a failing provider, and the log line says "Error generating response" for an error that had nothing to do with generation. 2. **The recovery is wrong, and expensive.** `attempt += 1` re-runs the whole LLM call — a fresh, billed completion — to recover from a deterministic tool bug. Re-asking the model does not fix a tool that raises on every call; it just pays for the same failure `retry_attempts` times. 3. **The two retry budgets compound.** With #1794 merged, `tool_execution_retry` retries `tool_retry_attempts` times internally and then raises into this handler, which retries the LLM `retry_attempts` times. Both default to 3, so one broken tool costs up to 9 tool executions and 3 completions. Note the trailing `Exception` in the handler tuple makes the three preceding provider-specific types redundant — the tuple catches everything regardless. ## Reproducer ```python from swarms import Agent calls = {"llm": 0, "tool": 0} def broken_tool(x: str) -> str: """A tool that always fails.""" calls["tool"] += 1 raise RuntimeError("tool is broken") agent = Agent(agent_name="A", model_name="gpt-4.1", max_loops=1, tools=[broken_tool], retry_attempts=3, tool_retry_attempts=3) # Count provider calls without needing a key: stub the LLM to always return a # tool call for broken_tool. ... agent.run("call broken_tool") print("LLM completions issued :", calls["llm"]) print("tool invocations :", calls["tool"]) ``` Expected on a correct implementation: the tool is retried, the run fails once, and **no** additional completion is issued to recover from it. ## Suggested change Let the tool path report itself as a tool failure rather than a generation failure. `AgentToolExecutionError` now has a raise site (#1794), so it can be distinguished before the generic branch: ```python except AgentToolExecutionError as e: capture_error( e, self, name="Agent.tool_error", loop=loop_count ) logger.error( f"Tool execution failed in loop {loop_count} for agent " f"'{self.agent_name}': {e}" ) raise except Exception as e: # existing generation-failure path, unchanged ``` Whether a tool failure should abort the run or break the loop is a product decision — the point is that it must not be counted, logged, or retried as a generation error. Narrowing the existing tuple to plain `Exception` at the same time would drop three redundant names. ## Environment `master` @ `a5764139` (v14.0.0), Python 3.12, macOS.