Skip to content

Commit bcf1220

Browse files
GregsGreyCodeclaude
andcommitted
feat: lazy tool loading — 31→11 tools, saves ~6K tokens per message
On models with ≤32K context, the agent now starts with 11 core tools (~3.5K tokens) instead of all 31 tools (~9.5K tokens). Extended tools are loaded on demand via the new request_tools() meta-tool. Core tools (always loaded): terminal, read_file, write_file, patch, search_files, memory, todo, clarify, request_tools, request_mcp_access, get_mcp_catalogue Extended tools (loaded via request_tools("category")): web, browser, image, vision, tts, delegation, code, workflows, cron, messaging, logs, skills, process, bugs, session Token impact for a "Hi!" on a 16K model: Before: system(8K) + tools(9.5K) + msg(1) = 17.5K (EXCEEDS 16K) After: system(8K) + tools(3.5K) + msg(1) = 11.5K (fits with 4.5K spare) Implementation: - tools/request_tools_tool.py (NEW): request_tools meta-tool with per-session tool grants. Same pattern as request_mcp_access. - tools/registry.py: get_definitions now checks requires_env — tools with missing API keys are excluded from schemas (Option 1) - core/model_tools.py: get_tool_definitions accepts lazy=True and session_id params. In lazy mode, intersects enabled toolset with CORE_TOOLS + session grants. - agents/hermes/agent.py: auto-enables lazy mode when context ≤32K. After request_tools() call, rebuilds tool list to include newly granted tools for the next API call. Cloud models (128K+) are unaffected — they get all 31 tools as before. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5f77c93 commit bcf1220

5 files changed

Lines changed: 251 additions & 4 deletions

File tree

agents/hermes/agent.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,12 +614,23 @@ def __init__(
614614
if fb_p and fb_m and not self.quiet_mode:
615615
print(f"🔄 Fallback model: {fb_m} ({fb_p})")
616616

617-
# Get available tools with filtering
617+
# Get available tools with filtering.
618+
# Lazy mode: when context is tight (≤32K), start with core tools only.
619+
# The agent can call request_tools() to load more when needed.
620+
_ctx_for_lazy = self.context_compressor.context_length if hasattr(self, 'context_compressor') else 128000
621+
_use_lazy = _ctx_for_lazy <= 32768
622+
self._lazy_tools = _use_lazy
623+
self._enabled_toolsets = enabled_toolsets
624+
self._disabled_toolsets = disabled_toolsets
618625
self.tools = get_tool_definitions(
619626
enabled_toolsets=enabled_toolsets,
620627
disabled_toolsets=disabled_toolsets,
621628
quiet_mode=self.quiet_mode,
629+
lazy=_use_lazy,
630+
session_id=self.session_id,
622631
)
632+
if _use_lazy and not self.quiet_mode:
633+
print(f"💡 Lazy tool loading (context {_ctx_for_lazy:,} ≤ 32K): {len(self.tools)} core tools loaded. Use request_tools() for more.")
623634

624635
# Show tool configuration and store valid tool names for validation
625636
self.valid_tool_names = set()
@@ -1641,6 +1652,8 @@ def _activate_honcho(
16411652
enabled_toolsets=enabled_toolsets,
16421653
disabled_toolsets=disabled_toolsets,
16431654
quiet_mode=True,
1655+
lazy=self._lazy_tools,
1656+
session_id=self.session_id,
16441657
)
16451658
self.valid_tool_names = {
16461659
tool["function"]["name"] for tool in self.tools
@@ -3629,7 +3642,7 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i
36293642
)
36303643
else:
36313644
from tools.registry import registry as _registry
3632-
return _registry.dispatch(
3645+
_result = _registry.dispatch(
36333646
function_name, function_args,
36343647
policy=self._action_policy,
36353648
session_id=self.session_id,
@@ -3638,6 +3651,22 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i
36383651
task_id=effective_task_id,
36393652
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
36403653
)
3654+
# Lazy tool loading: if request_tools was called, refresh the tool
3655+
# list so newly granted tools appear in the next API call.
3656+
if function_name == "request_tools" and self._lazy_tools and self.session_id:
3657+
try:
3658+
self.tools = get_tool_definitions(
3659+
enabled_toolsets=self._enabled_toolsets,
3660+
disabled_toolsets=self._disabled_toolsets,
3661+
quiet_mode=True,
3662+
lazy=True,
3663+
session_id=self.session_id,
3664+
)
3665+
self.valid_tool_names = {t["function"]["name"] for t in self.tools}
3666+
logger.info("request_tools: refreshed tool list → %d tools", len(self.tools))
3667+
except Exception:
3668+
pass
3669+
return _result
36413670

36423671
def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
36433672
"""Execute multiple tool calls concurrently using a thread pool.

core/model_tools.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def _discover_tools():
102102
# Gateway MCP access tool — request_mcp_access + get_mcp_catalogue
103103
# Always registered so agents can request MCP access regardless of mode.
104104
"tools.mcp_access_tool",
105+
# Lazy tool loading — request_tools meta-tool for on-demand tool injection.
106+
"tools.request_tools_tool",
105107
]
106108
import importlib
107109
for mod_name in _modules:
@@ -178,6 +180,8 @@ def get_tool_definitions(
178180
enabled_toolsets: List[str] = None,
179181
disabled_toolsets: List[str] = None,
180182
quiet_mode: bool = False,
183+
lazy: bool = False,
184+
session_id: str = None,
181185
) -> List[Dict[str, Any]]:
182186
"""
183187
Get tool definitions for model API calls with toolset-based filtering.
@@ -188,6 +192,9 @@ def get_tool_definitions(
188192
enabled_toolsets: Only include tools from these toolsets.
189193
disabled_toolsets: Exclude tools from these toolsets (if enabled_toolsets is None).
190194
quiet_mode: Suppress status prints.
195+
lazy: If True, only return core tools + request_tools + session-granted tools.
196+
Extended tools are loaded on demand via request_tools().
197+
session_id: Session ID for looking up dynamically granted tools.
191198
192199
Returns:
193200
Filtered list of OpenAI-format tool definitions.
@@ -235,7 +242,26 @@ def get_tool_definitions(
235242
for ts_name in get_all_toolsets():
236243
tools_to_include.update(resolve_toolset(ts_name))
237244

238-
# Ask the registry for schemas (only returns tools whose check_fn passes)
245+
# Lazy mode: restrict to core tools + request_tools + session-granted tools.
246+
# Core tools are always included even if they're not in the platform toolset.
247+
if lazy and tools_to_include:
248+
from tools.request_tools_tool import CORE_TOOLS, get_granted_tools
249+
_core = set(CORE_TOOLS)
250+
_core.add("request_tools") # always include the meta-tool
251+
_core.add("request_mcp_access") # always include MCP access
252+
_core.add("get_mcp_catalogue") # always include MCP catalogue
253+
if session_id:
254+
_core |= set(get_granted_tools(session_id))
255+
# Keep only core + granted tools from the enabled set, plus force-include
256+
# the meta-tools even if they're not in the platform toolset.
257+
_allowed = (tools_to_include & _core) | {"request_tools", "request_mcp_access", "get_mcp_catalogue"}
258+
_full_count = len(tools_to_include)
259+
tools_to_include = _allowed
260+
if not quiet_mode:
261+
logger.info("Lazy tool loading: %d → %d tools (extended available via request_tools)", _full_count, len(tools_to_include))
262+
263+
# Ask the registry for schemas (only returns tools whose check_fn passes
264+
# and whose requires_env keys are present)
239265
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)
240266

241267
# Rebuild execute_code schema to only list sandbox tools that are actually

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "logos"
7-
version = "0.6.2"
7+
version = "0.6.3"
88
description = "A self-hosted agent platform — inference routing, multi-model benchmarking, and policy-governed agent runs across local and cloud hardware"
99
readme = "README.md"
1010
requires-python = ">=3.11"

tools/registry.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,22 @@ def get_definitions(self, tool_names: Set[str], quiet: bool = False) -> List[dic
8585
"""Return OpenAI-format tool schemas for the requested tool names.
8686
8787
Only tools whose ``check_fn()`` returns True (or have no check_fn)
88+
AND whose ``requires_env`` keys are all present in os.environ
8889
are included.
8990
"""
91+
import os as _os
9092
result = []
9193
for name in sorted(tool_names):
9294
entry = self._tools.get(name)
9395
if not entry:
9496
continue
97+
# Skip tools with unmet environment requirements
98+
if entry.requires_env:
99+
missing = [k for k in entry.requires_env if not _os.environ.get(k)]
100+
if missing:
101+
if not quiet:
102+
logger.debug("Tool %s unavailable (missing env: %s)", name, missing)
103+
continue
95104
if entry.check_fn:
96105
try:
97106
if not entry.check_fn():

tools/request_tools_tool.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
request_tools — lazy tool loading meta-tool.
3+
4+
Agents start with a small set of core tools to keep token usage low.
5+
When they need additional capabilities (web search, browser, image gen,
6+
etc.), they call request_tools(categories) to inject those tool schemas
7+
into the next API call.
8+
9+
This follows the same pattern as request_mcp_access — the agent asks
10+
for what it needs, the gateway provides it.
11+
12+
Core tools (~2-3K tokens, always loaded):
13+
terminal, read_file, write_file, patch, search_files,
14+
memory, todo, clarify, request_tools
15+
16+
Extended tools (~6-7K tokens, loaded on demand):
17+
web, browser, image, vision, tts, delegation, code,
18+
workflows, cron, messaging, logs, skills, process, bugs
19+
20+
The split point is: can the agent have a useful conversation with just
21+
the core tools? Yes — it can read/write files, run commands, search
22+
code, and remember things. The extended tools are for specific tasks.
23+
"""
24+
25+
import json
26+
import logging
27+
28+
logger = logging.getLogger(__name__)
29+
30+
# ---------------------------------------------------------------------------
31+
# Tool categories → toolset names (maps user-friendly names to registry IDs)
32+
# ---------------------------------------------------------------------------
33+
34+
TOOL_CATEGORIES = {
35+
"web": {"tools": ["web_search", "web_extract"], "description": "Web search and content extraction (Firecrawl)"},
36+
"browser": {"tools": ["browser_navigate", "browser_click", "browser_type", "browser_snapshot", "browser_scroll", "browser_press", "browser_back", "browser_close", "browser_get_images", "browser_vision", "browser_console"], "description": "Browser automation"},
37+
"image": {"tools": ["image_generate"], "description": "Image generation (fal.ai)"},
38+
"vision": {"tools": ["vision_analyze"], "description": "Image analysis using AI vision"},
39+
"tts": {"tools": ["text_to_speech"], "description": "Text-to-speech audio generation"},
40+
"delegation": {"tools": ["delegate_task", "execute_code", "mixture_of_agents"], "description": "Subagent spawning and programmatic tool calling"},
41+
"workflows": {"tools": ["workflow"], "description": "Multi-step DAG task workflows"},
42+
"cron": {"tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], "description": "Scheduled task management"},
43+
"messaging": {"tools": ["send_message"], "description": "Cross-platform message delivery"},
44+
"logs": {"tools": ["log_inspector"], "description": "Runtime log analysis"},
45+
"skills": {"tools": ["skill_manage", "skill_view", "skills_list"], "description": "Skill management and browsing"},
46+
"process": {"tools": ["process"], "description": "Background process management"},
47+
"bugs": {"tools": ["bug_notes"], "description": "Self-reported bug tracking"},
48+
"session": {"tools": ["session_search"], "description": "Long-term conversation memory search"},
49+
}
50+
51+
# Core tools — always loaded regardless of lazy mode
52+
CORE_TOOLS = frozenset({
53+
"terminal",
54+
"read_file",
55+
"write_file",
56+
"patch",
57+
"search_files",
58+
"memory",
59+
"todo",
60+
"clarify",
61+
})
62+
63+
# ---------------------------------------------------------------------------
64+
# Session-level tool grants (same pattern as mcp_access)
65+
# ---------------------------------------------------------------------------
66+
67+
import threading
68+
69+
_lock = threading.Lock()
70+
_session_tools: dict[str, set[str]] = {} # session_id → set of granted tool names
71+
72+
73+
def grant_tools(session_id: str, tool_names: list[str]) -> None:
74+
"""Grant additional tools to a session."""
75+
with _lock:
76+
if session_id not in _session_tools:
77+
_session_tools[session_id] = set()
78+
_session_tools[session_id].update(tool_names)
79+
80+
81+
def get_granted_tools(session_id: str) -> frozenset[str]:
82+
"""Return the set of tools granted to this session beyond core."""
83+
with _lock:
84+
return frozenset(_session_tools.get(session_id, set()))
85+
86+
87+
def clear_session(session_id: str) -> None:
88+
"""Clean up when session ends."""
89+
with _lock:
90+
_session_tools.pop(session_id, None)
91+
92+
93+
# ---------------------------------------------------------------------------
94+
# Handler
95+
# ---------------------------------------------------------------------------
96+
97+
_TOOL_NAME = "request_tools"
98+
99+
100+
def _handler(args: dict, **kwargs) -> str:
101+
categories = args.get("categories") or []
102+
session_id = kwargs.get("session_id")
103+
104+
if not categories:
105+
# List available categories
106+
cat_list = []
107+
for cat, info in TOOL_CATEGORIES.items():
108+
cat_list.append(f" {cat}: {info['description']} ({len(info['tools'])} tools)")
109+
return json.dumps({
110+
"available_categories": list(TOOL_CATEGORIES.keys()),
111+
"details": "\n".join(cat_list),
112+
"message": "Call request_tools with the categories you need.",
113+
})
114+
115+
granted = []
116+
not_found = []
117+
for cat in categories:
118+
cat = cat.strip().lower()
119+
if cat in TOOL_CATEGORIES:
120+
tools = TOOL_CATEGORIES[cat]["tools"]
121+
if session_id:
122+
grant_tools(session_id, tools)
123+
granted.extend(tools)
124+
logger.info("request_tools: granted %s tools to session %s: %s", cat, session_id, tools)
125+
else:
126+
not_found.append(cat)
127+
128+
result = {
129+
"status": "granted",
130+
"tools_added": granted,
131+
"message": f"Added {len(granted)} tools. They will be available from your next message.",
132+
}
133+
if not_found:
134+
result["not_found"] = not_found
135+
result["available_categories"] = list(TOOL_CATEGORIES.keys())
136+
137+
return json.dumps(result)
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# Self-registration
142+
# ---------------------------------------------------------------------------
143+
144+
def _register():
145+
try:
146+
from tools.registry import registry
147+
148+
cat_names = ", ".join(TOOL_CATEGORIES.keys())
149+
schema = {
150+
"name": _TOOL_NAME,
151+
"description": (
152+
"Request additional tool capabilities beyond the core set. "
153+
f"Available categories: {cat_names}. "
154+
"Call with no arguments to see descriptions. "
155+
"Tools are added to your session and available from the next message."
156+
),
157+
"parameters": {
158+
"type": "object",
159+
"properties": {
160+
"categories": {
161+
"type": "array",
162+
"items": {"type": "string"},
163+
"description": f"Tool categories to load. Available: {cat_names}",
164+
},
165+
},
166+
"required": [],
167+
},
168+
}
169+
170+
registry.register(
171+
name=_TOOL_NAME,
172+
toolset="core",
173+
schema=schema,
174+
handler=_handler,
175+
is_async=False,
176+
description=schema["description"],
177+
)
178+
logger.debug("request_tools: registered")
179+
except Exception as exc:
180+
logger.debug("request_tools: registration failed: %s", exc)
181+
182+
183+
_register()

0 commit comments

Comments
 (0)