Skip to content

Commit 2c09065

Browse files
committed
[Improvement] Autonomous loop: load tool schemas on demand via search_tools (kyegomez#1753)
get_autonomous_planning_tools() returns 16 tools totalling ~2,319 tokens of schema, and max_loops="auto" front-loads all of them on every run. Most tasks use three or four. Because the loop flattens context into one ever-changing user message the block is never cached, so it is re-sent and re-billed on every iteration, up to MAX_SUBTASK_ITERATIONS = 100. selected_tools="lazy" ships a five-tool core -- create_plan, subtask_done, complete_task, respond_to_user and the search_tools meta-tool -- and lets the agent fetch the rest by intent: search_tools("run a shell command") -> loads run_bash, grep Measured on the current tool set: 2,319 -> 712 tokens resident, a 69% reduction, and the resident cost now stays flat as tools are added rather than growing with the catalogue. Matching scores query words against tool name and description, so the model describes what it wants instead of guessing exact names, which is what made the existing selected_tools filter unusable for this -- it required the caller to know the names up front. A query that matches nothing returns the remaining tool *names* rather than every remaining schema. Returning everything would mean one bad search silently undoes the saving. Defaults are untouched: selected_tools stays "all".
1 parent f894187 commit 2c09065

3 files changed

Lines changed: 329 additions & 9 deletions

File tree

swarms/structs/agent.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
)
7474
from swarms.structs.agent_roles import agent_roles
7575
from swarms.structs.autonomous_loop_utils import (
76+
get_lazy_autonomous_tools,
77+
search_tools_tool,
7678
MAX_PLANNING_ATTEMPTS,
7779
MAX_SUBTASK_ITERATIONS,
7880
MAX_SUBTASK_LOOPS,
@@ -1980,12 +1982,24 @@ def _run_autonomous_loop(
19801982
self.plan_created = False
19811983
self.think_call_count = 0
19821984

1983-
# Add planning tools to tools_list_dictionary
1984-
planning_tools = get_autonomous_planning_tools()
1985+
# Add planning tools to tools_list_dictionary.
1986+
# "lazy" ships a small always-on core plus the search_tools
1987+
# meta-tool; the rest are fetched on demand, which keeps the
1988+
# resident schema block from being re-sent on every iteration.
1989+
if self.selected_tools == "lazy":
1990+
planning_tools = get_lazy_autonomous_tools()
1991+
logger.info(
1992+
f"Autonomous looper using lazy tool loading: "
1993+
f"{len(planning_tools)} core tools, the rest available "
1994+
f"via search_tools"
1995+
)
1996+
else:
1997+
planning_tools = get_autonomous_planning_tools()
19851998

19861999
# Filter planning tools if selected_tools is not "all"
19872000
if (
19882001
self.selected_tools != "all"
2002+
and self.selected_tools != "lazy"
19892003
and self.selected_tools is not None
19902004
):
19912005
logger.info(
@@ -2097,9 +2111,16 @@ def _run_autonomous_loop(
20972111
),
20982112
}
20992113

2100-
# Filter tool handlers if selected_tools is not "all"
2114+
all_planning_tool_handlers["search_tools"] = (
2115+
lambda **kwargs: search_tools_tool(self, **kwargs)
2116+
)
2117+
2118+
# Filter tool handlers if selected_tools is not "all".
2119+
# "lazy" keeps every handler: the model can load any schema at
2120+
# run time, so the handler has to be there when it does.
21012121
if (
21022122
self.selected_tools != "all"
2123+
and self.selected_tools != "lazy"
21032124
and self.selected_tools is not None
21042125
):
21052126
planning_tool_handlers = {
@@ -5396,16 +5417,14 @@ def execute_tools(self, response: any, loop_count: int):
53965417
if self.tool_call_summary is True:
53975418
temp_llm = self.temp_llm_instance_for_tool_summary()
53985419

5399-
tool_response = temp_llm.run(
5400-
f"""
5420+
tool_response = temp_llm.run(f"""
54015421
Please analyze and summarize the following tool execution output in a clear and concise way.
54025422
Focus on the key information and insights that would be most relevant to the user's original request.
54035423
If there are any errors or issues, highlight them prominently.
54045424
54055425
Tool Output:
54065426
{output}
5407-
"""
5408-
)
5427+
""")
54095428

54105429
self.short_memory.add(
54115430
role=self.agent_name,

swarms/structs/autonomous_loop_utils.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import re as _re
3131
import subprocess
3232
import uuid
33-
from typing import Any, Dict, List
33+
from typing import Any, Dict, List, Optional
3434

3535
from loguru import logger
3636

@@ -584,6 +584,124 @@ def get_autonomous_planning_tools() -> List[Dict[str, Any]]:
584584
]
585585

586586

587+
# The always-resident set under selected_tools="lazy". Everything the loop
588+
# structurally depends on (planning, subtask bookkeeping, termination, and the
589+
# user-facing reply), plus the meta-tool used to fetch the rest. Keeping this
590+
# small is the point: the resident schema cost stays flat as tools are added.
591+
LAZY_CORE_TOOL_NAMES = (
592+
"create_plan",
593+
"subtask_done",
594+
"complete_task",
595+
"respond_to_user",
596+
"search_tools",
597+
)
598+
599+
600+
def get_search_tools_schema() -> Dict[str, Any]:
601+
"""
602+
Schema for the ``search_tools`` meta-tool.
603+
604+
Returns:
605+
Dict[str, Any]: An OpenAI function-calling definition.
606+
"""
607+
return {
608+
"type": "function",
609+
"function": {
610+
"name": "search_tools",
611+
"description": (
612+
"Look up additional tools you can use. Only a small core set "
613+
"is loaded up front; call this with a short description of "
614+
"what you need (for example 'read a file', 'run a shell "
615+
"command', 'delegate to a sub agent') to load the matching "
616+
"tools, after which you can call them directly."
617+
),
618+
"parameters": {
619+
"type": "object",
620+
"properties": {
621+
"query": {
622+
"type": "string",
623+
"description": (
624+
"What you are trying to do, or a tool name."
625+
),
626+
}
627+
},
628+
"required": ["query"],
629+
},
630+
},
631+
}
632+
633+
634+
def search_autonomous_tools(
635+
query: str, exclude: Optional[List[str]] = None
636+
) -> List[Dict[str, Any]]:
637+
"""
638+
Find built-in tool schemas matching a free-text query.
639+
640+
Scores each non-core tool on whether the query's words appear in its name
641+
or description, so "read a file" surfaces read_file and list_directory
642+
without needing the caller to know the exact names.
643+
644+
Args:
645+
query (str): Free-text description of the capability wanted.
646+
exclude (Optional[List[str]]): Tool names already loaded.
647+
648+
Returns:
649+
List[Dict[str, Any]]: Matching schemas, best match first. An empty
650+
query returns every unloaded tool; a query that matches nothing
651+
returns an empty list, so a repeated search cannot quietly load the
652+
whole catalogue and undo the saving. The caller surfaces the
653+
available names instead, which costs a few tokens rather than a few
654+
hundred.
655+
"""
656+
exclude_set = set(exclude or ()) | set(LAZY_CORE_TOOL_NAMES)
657+
candidates = [
658+
t
659+
for t in get_autonomous_planning_tools()
660+
if t.get("function", {}).get("name") not in exclude_set
661+
]
662+
663+
words = [
664+
w
665+
for w in _re.findall(r"[a-z0-9]+", query.lower())
666+
if len(w) > 2
667+
]
668+
if not words:
669+
return candidates
670+
671+
scored = []
672+
for tool in candidates:
673+
fn = tool.get("function", {})
674+
name = fn.get("name", "").lower()
675+
haystack = f"{name} {fn.get('description', '').lower()}"
676+
score = sum(
677+
(3 if w in name else 0) + (1 if w in haystack else 0)
678+
for w in words
679+
)
680+
if score:
681+
scored.append((score, name, tool))
682+
683+
if not scored:
684+
return []
685+
686+
scored.sort(key=lambda item: (-item[0], item[1]))
687+
return [tool for _, _, tool in scored]
688+
689+
690+
def get_lazy_autonomous_tools() -> List[Dict[str, Any]]:
691+
"""
692+
The core tool set for ``selected_tools="lazy"``.
693+
694+
Returns:
695+
List[Dict[str, Any]]: Core schemas plus the ``search_tools`` meta-tool.
696+
"""
697+
core = [
698+
t
699+
for t in get_autonomous_planning_tools()
700+
if t.get("function", {}).get("name") in LAZY_CORE_TOOL_NAMES
701+
]
702+
return core + [get_search_tools_schema()]
703+
704+
587705
def get_autonomous_loop_tool_names() -> List[str]:
588706
"""
589707
Return a list of all autonomous loop tool names.
@@ -603,6 +721,61 @@ def get_autonomous_loop_tool_names() -> List[str]:
603721
# ============================================================================
604722

605723

724+
def search_tools_tool(agent: Any, query: str = "", **kwargs) -> str:
725+
"""
726+
Handler for the ``search_tools`` meta-tool: load matching schemas on demand.
727+
728+
Appends the matched definitions to the agent's ``tools_list_dictionary``
729+
and rebuilds the LLM client, so the tools are callable from the next
730+
iteration onward. Already-loaded tools are excluded, so repeated searches
731+
do not duplicate schemas.
732+
733+
Args:
734+
agent (Any): The agent making the call.
735+
query (str): What the agent is trying to do.
736+
737+
Returns:
738+
str: A human-readable summary of what was loaded.
739+
"""
740+
loaded = [
741+
t.get("function", {}).get("name")
742+
for t in (agent.tools_list_dictionary or [])
743+
]
744+
matches = search_autonomous_tools(query, exclude=loaded)
745+
if not matches:
746+
remaining = [
747+
t.get("function", {}).get("name")
748+
for t in get_autonomous_planning_tools()
749+
if t.get("function", {}).get("name") not in loaded
750+
]
751+
if not remaining:
752+
return "Every available tool is already loaded."
753+
return (
754+
f"No tool matched '{query}'. Still available, by name: "
755+
f"{', '.join(remaining)}. Search again using one of these names."
756+
)
757+
758+
if agent.tools_list_dictionary is None:
759+
agent.tools_list_dictionary = []
760+
agent.tools_list_dictionary.extend(matches)
761+
762+
# Rebuild so the newly added schemas reach the provider. Without this the
763+
# client keeps the tool list it was constructed with and the model would
764+
# be told about tools it cannot actually call.
765+
try:
766+
agent.llm = agent.llm_handling()
767+
except Exception as e:
768+
logger.error(
769+
f"search_tools could not rebuild the LLM client: {e}"
770+
)
771+
772+
names = [t.get("function", {}).get("name") for t in matches]
773+
return (
774+
f"Loaded {len(names)} tool(s) for '{query}': {', '.join(names)}. "
775+
f"You can now call them directly."
776+
)
777+
778+
606779
def respond_to_user_tool(
607780
agent: Any, message: str, message_type: str = "info", **kwargs
608781
) -> str:

0 commit comments

Comments
 (0)