Compare commits

...

17 Commits

Author SHA1 Message Date
alt-glitch
2c4f3ea196 chore: remove vendor-specific references from app_tools 2026-05-23 22:59:01 +05:30
alt-glitch
cb12ee4b2d fix: use 'is not None' checks for session/session_id, remove dead _EXECUTE_STRIP_KEYS
- 'if session:' drops empty dict {} which is schema-valid
- 'if session_id:' drops empty string which shouldn't be silently eaten
- _EXECUTE_STRIP_KEYS frozenset was defined but never referenced (handler
  uses allowlist approach instead)
2026-05-23 22:19:24 +05:30
alt-glitch
a57781f8a9 refactor: address code review findings for app_tools
- Remove unused build_app_tools_prompt import from run_agent.py
- Remove unnecessary portal config write from migration (deep-merge
  handles it); keep platform_toolsets injection which deep-merge can't
- Deduplicate _read_portal_app_tools_enabled into tool_backend_helpers.py
- Cache httpx.Client at module level (thread-safe, staleness-checked)
  to avoid TCP+TLS setup per tool call
- Extract local vars for triple-repeated gateway availability expression
  in get_nous_subscription_features
- Update test mocks to accept **kw for per-request timeout kwarg
- Add autouse fixture to reset cached http client between tests
2026-05-23 22:13:08 +05:30
alt-glitch
6749e335a3 fix: inject app_tools into saved platform_toolsets during migration
Users who previously ran 'hermes tools' have explicit platform_toolsets
lists in config.yaml. The v24 migration added portal.app_tools config
but didn't inject app_tools into those saved lists, so the toolset
was invisible at runtime despite check_fn passing.
2026-05-23 21:16:42 +05:30
alt-glitch
53814b39c3 fix: strengthen app_tools behavioral prompt to preempt skill loading
The LLM was loading skills like 'linear', 'composio', 'airtable' instead
of calling app_search_tools directly. Explicitly name the skills to avoid
and make the preference stronger.
2026-05-23 21:08:04 +05:30
alt-glitch
efd71e8914 Revert "fix: use resolved_origin and Host header in app_tools gateway client"
This reverts commit bc2ba1356e.
2026-05-23 20:52:08 +05:30
alt-glitch
bc2ba1356e fix: use resolved_origin and Host header in app_tools gateway client
_gateway_post() was using gateway_origin directly, which fails on
*.localhost subdomains (Python DNS can't resolve them). Now uses
resolved_origin (127.0.0.1 rewrite) and sets the Host header for
reverse-proxy routing. Also disables TLS verification for rewritten
localhost origins (self-signed dev certs).
2026-05-23 20:45:12 +05:30
alt-glitch
e0b3fa6eb3 feat: add PORTAL_APP_TOOLS to OPTIONAL_ENV_VARS for discoverability 2026-05-23 20:34:54 +05:30
alt-glitch
929245ba69 fix: add app_tools to mock NousSubscriptionFeatures in existing tests
The items() ordered tuple now includes 'app_tools', so test fixtures
that construct NousSubscriptionFeatures must include the key to avoid
KeyError when iterating.
2026-05-23 20:27:25 +05:30
alt-glitch
73a3de5798 fix: strengthen app_tools prompt, add to CONFIGURABLE_TOOLSETS 2026-05-22 21:02:02 +05:30
alt-glitch
3a26076194 fix: rewrite *.localhost origins to 127.0.0.1 for Python DNS compatibility 2026-05-22 20:46:07 +05:30
alt-glitch
04d3a2e2be test: add unit tests for app_tools gateway handlers 2026-05-22 19:35:34 +05:30
alt-glitch
70882abe9b feat: add app_tools to hermes status and subscription features 2026-05-22 19:33:57 +05:30
alt-glitch
2771d404a3 feat: inject app tools behavioral guidance into system prompt 2026-05-22 19:31:36 +05:30
alt-glitch
7150715e19 feat: register app_tools toolset 2026-05-22 19:28:05 +05:30
alt-glitch
4eab358ff7 feat: add app_tools gateway handlers and tool registration 2026-05-22 19:27:13 +05:30
alt-glitch
f96db81d3b feat: add portal.app_tools config key with migration 2026-05-22 19:25:19 +05:30
19 changed files with 945 additions and 12 deletions

View File

@@ -1258,6 +1258,10 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
"terminal",
"process",
"execute_code",
"app_search_tools",
"app_tool_schemas",
"app_execute_tools",
"app_manage_connections",
}
if valid_names and not (valid_names & relevant_tool_names):
@@ -1279,7 +1283,7 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, browser automation (Browser Use), and app integrations (500+ apps) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
@@ -1294,6 +1298,39 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
return "\n".join(lines)
# =========================================================================
# App tools (500+ external integrations) behavioural prompt
# =========================================================================
_APP_TOOLS_PROMPT = """\
## App Tools (500+ External Integrations)
You have app_search_tools, app_tool_schemas, app_execute_tools, and app_manage_connections available RIGHT NOW as callable tools. They are already configured and connected to the Nous tool gateway — no SDK installation, no API keys, no plugin setup needed. Just call them.
**When to use:** When a user asks to interact with ANY external app or service — Gmail, Slack, GitHub, Jira, Notion, Google Sheets, Linear, HubSpot, Figma, Salesforce, or any of 500+ other apps. ALWAYS prefer these tools over loading skills about the same service (e.g. do NOT load the 'linear', 'airtable', 'google-workspace', 'notion', or any similar skill — use app_search_tools instead). Do NOT suggest installing SDKs, CLI tools, MCP servers, or API keys for external services — call app_search_tools directly.
**Workflow:**
1. Call app_search_tools with a clear use_case description to discover available tools
2. Check the response for connection status — if no active connection, call app_manage_connections and share the auth link with the user
3. Review the execution plan and pitfalls in the search response before executing
4. If a tool has schemaRef instead of input_schema, call app_tool_schemas to get the full schema
5. Execute tools via app_execute_tools with schema-compliant arguments
**Session tracking:** Pass session: {generate_id: true} on your first app_search_tools call. Reuse the returned session.id in all subsequent calls. Generate a new session when the user pivots to a different task.
**Important:** Never fabricate tool slugs or argument field names. Only use slugs and schemas returned by app_search_tools or app_tool_schemas."""
def build_app_tools_prompt(valid_tool_names: "set[str] | None" = None) -> str:
"""Return the app tools behavioural guidance when the toolset is active."""
if valid_tool_names and "app_search_tools" not in valid_tool_names:
return ""
if not valid_tool_names:
# No tool names known — skip (conservative)
return ""
return _APP_TOOLS_PROMPT
# =========================================================================
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================

View File

@@ -130,6 +130,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
nous_subscription_prompt = _r.build_nous_subscription_prompt(agent.valid_tool_names)
if nous_subscription_prompt:
stable_parts.append(nous_subscription_prompt)
# App tools (500+ external integrations) behavioural guidance
app_tools_prompt = _r.build_app_tools_prompt(agent.valid_tool_names)
if app_tools_prompt:
stable_parts.append(app_tools_prompt)
# Tool-use enforcement: tells the model to actually call tools instead
# of describing intended actions. Controlled by config.yaml
# agent.tool_use_enforcement:

View File

@@ -1778,8 +1778,17 @@ DEFAULT_CONFIG = {
},
},
# ── Nous Portal feature flags ──────────────────────────────────────
"portal": {
# App tools: 500+ external app integrations (Gmail, Slack, GitHub,
# Notion, etc.) via the Nous tool gateway. Requires an active Nous
# subscription. Set to False to hide the app_tools toolset even
# when a subscription is present.
"app_tools": True,
},
# Config schema version - bump this when adding new required fields
"_config_version": 23,
"_config_version": 24,
}
# =============================================================================
@@ -2267,6 +2276,22 @@ OPTIONAL_ENV_VARS = {
"category": "tool",
"advanced": True,
},
"TOOLS_GATEWAY_URL": {
"description": "Explicit URL for the tools-gateway (app integrations). Overrides the auto-derived tools-gateway.nousresearch.com",
"prompt": "Tools-gateway URL",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"PORTAL_APP_TOOLS": {
"description": "Enable app integration tools (500+ apps via Nous tool gateway). Requires Nous subscription.",
"prompt": "Enable app tools (500+ apps)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TAVILY_API_KEY": {
"description": "Tavily API key for AI-native web search, extract, and crawl",
"prompt": "Tavily API key",
@@ -3301,7 +3326,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions",
"sessions", "portal",
}
# Valid fields inside a custom_providers list entry
@@ -3964,6 +3989,26 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
f"{', '.join(added_aux)}"
)
# ── Version 23 → 24: inject app_tools into saved platform_toolsets ──
# The portal.app_tools config flag is handled by deep-merge (DEFAULT_CONFIG
# has it, so load_config() always includes it). But platform_toolsets are
# user-owned lists that deep-merge can't append to — existing users who
# ran `hermes tools` have a saved list that won't include app_tools.
if current_ver < 24:
config = read_raw_config()
pt = config.get("platform_toolsets")
if isinstance(pt, dict):
patched = False
for plat_key, ts_list in pt.items():
if isinstance(ts_list, list) and "app_tools" not in ts_list:
ts_list.append("app_tools")
patched = True
if patched:
save_config(config)
results["config_added"].append("app_tools added to platform_toolsets")
if not quiet:
print(" ✓ Added app_tools to saved platform toolset lists")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")

View File

@@ -74,8 +74,12 @@ class NousSubscriptionFeatures:
def modal(self) -> NousFeatureState:
return self.features["modal"]
@property
def app_tools(self) -> NousFeatureState:
return self.features["app_tools"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "tts", "browser", "modal")
ordered = ("web", "image_gen", "tts", "browser", "modal", "app_tools")
for key in ordered:
yield self.features[key]
@@ -225,6 +229,22 @@ def _resolve_browser_feature_state(
return "local", available, active, False
def _read_portal_app_tools_enabled(config: Optional[Dict[str, object]] = None) -> bool:
"""Return True when the portal.app_tools config flag is on."""
if config is not None:
# Fast path: use the pre-loaded config snapshot from the caller
import os
env_val = os.getenv("PORTAL_APP_TOOLS")
if env_val is not None:
return is_truthy_value(env_val)
portal = config.get("portal")
if isinstance(portal, dict):
return bool(portal.get("app_tools", True))
return True
from tools.tool_backend_helpers import portal_app_tools_enabled
return portal_app_tools_enabled()
def get_nous_subscription_features(
config: Optional[Dict[str, object]] = None,
) -> NousSubscriptionFeatures:
@@ -313,6 +333,8 @@ def get_nous_subscription_features(
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
app_gw_ready = bool(managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("tools"))
app_config_on = _read_portal_app_tools_enabled(config)
modal_state = resolve_modal_backend_state(
modal_mode,
has_direct=direct_modal,
@@ -476,6 +498,17 @@ def get_nous_subscription_features(
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
explicit_configured=terminal_backend == "modal",
),
"app_tools": NousFeatureState(
key="app_tools",
label="App tools (500+ apps)",
included_by_default=True,
available=app_gw_ready,
active=app_gw_ready and app_config_on,
managed_by_nous=app_gw_ready and app_config_on,
direct_override=False,
toolset_enabled=app_config_on,
current_provider="Nous Tool Gateway",
),
}
return NousSubscriptionFeatures(

View File

@@ -78,6 +78,7 @@ CONFIGURABLE_TOOLSETS = [
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
("app_tools", "🔌 App Integrations (500+)", "Gmail, Slack, GitHub, Jira, Notion, etc. via Nous tool gateway"),
]
# Toolsets that are OFF by default for new installs.

View File

@@ -148,7 +148,7 @@ class BrowserUseBrowserProvider(BrowserProvider):
return {
"api_key": managed.nous_user_token,
"base_url": managed.gateway_origin.rstrip("/"),
"base_url": managed.resolved_origin.rstrip("/"),
"managed_mode": True,
}

View File

@@ -238,7 +238,7 @@ def _get_firecrawl_client() -> Any:
kwargs = {
"api_key": managed_gateway.nous_user_token,
"api_url": managed_gateway.gateway_origin,
"api_url": managed_gateway.resolved_origin,
}
client_config = (
"tool-gateway",

View File

@@ -444,6 +444,7 @@ class TestBuildNousSubscriptionPrompt:
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
"app_tools": NousFeatureState("app_tools", "App tools (500+ apps)", True, True, True, True, False, True, "Nous Subscription"),
},
),
)
@@ -468,6 +469,7 @@ class TestBuildNousSubscriptionPrompt:
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""),
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""),
"app_tools": NousFeatureState("app_tools", "App tools (500+ apps)", True, False, False, False, False, True, ""),
},
),
)

View File

@@ -90,6 +90,7 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
"app_tools": NousFeatureState("app_tools", "App tools (500+ apps)", True, True, True, True, False, True, "Nous Subscription"),
},
),
raising=False,

View File

@@ -0,0 +1,246 @@
"""Unit tests for tools/app_tools.py — the Nous tool gateway integration."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import httpx
import pytest
from tools.managed_tool_gateway import ManagedToolGatewayConfig
_FAKE_GATEWAY = ManagedToolGatewayConfig(
vendor="tools",
gateway_origin="https://tools-gateway.example.com",
nous_user_token="test-token-abc123",
managed_mode=True,
)
@pytest.fixture(autouse=True)
def _reset_http_client_cache():
"""Clear the module-level cached httpx client between tests."""
import tools.app_tools as mod
mod._http_client = None
mod._http_client_origin = None
yield
mod._http_client = None
mod._http_client_origin = None
@pytest.fixture()
def gateway_post(monkeypatch):
"""Patch the gateway and httpx.Client.post; return a dict capturing the request."""
monkeypatch.setattr(
"tools.app_tools.resolve_managed_tool_gateway", lambda v: _FAKE_GATEWAY
)
monkeypatch.setattr(
"tools.app_tools._get_current_model_name", lambda: None
)
captured = {}
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.json.return_value = {"data": {}, "error": None}
resp.text = json.dumps({"data": {}, "error": None})
def fake_post(self, url, *, json=None, headers=None, **kw):
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
return resp
monkeypatch.setattr(httpx.Client, "post", fake_post)
return captured
# ---------------------------------------------------------------------------
# check_fn gating
# ---------------------------------------------------------------------------
class TestAppToolsAvailability:
def test_returns_false_when_gateway_not_ready(self, monkeypatch):
monkeypatch.setattr("tools.app_tools.is_managed_tool_gateway_ready", lambda vendor: False)
monkeypatch.setattr("tools.app_tools._read_portal_app_tools_enabled", lambda: True)
from tools.app_tools import _app_tools_available
assert _app_tools_available() is False
def test_returns_true_when_gateway_ready_and_config_on(self, monkeypatch):
monkeypatch.setattr("tools.app_tools.is_managed_tool_gateway_ready", lambda vendor: True)
monkeypatch.setattr("tools.app_tools._read_portal_app_tools_enabled", lambda: True)
from tools.app_tools import _app_tools_available
assert _app_tools_available() is True
def test_returns_false_when_config_off(self, monkeypatch):
monkeypatch.setattr("tools.app_tools.is_managed_tool_gateway_ready", lambda vendor: True)
monkeypatch.setattr("tools.app_tools._read_portal_app_tools_enabled", lambda: False)
from tools.app_tools import _app_tools_available
assert _app_tools_available() is False
# ---------------------------------------------------------------------------
# URL + auth header
# ---------------------------------------------------------------------------
class TestSearchPostsCorrectUrlAndAuth:
def test_posts_to_v1_search_with_bearer_token(self, monkeypatch, gateway_post):
monkeypatch.setattr("tools.app_tools._get_current_model_name", lambda: "test-model")
from tools.app_tools import handle_app_search_tools
handle_app_search_tools({"queries": [{"use_case": "send email"}]})
assert gateway_post["url"] == "https://tools-gateway.example.com/v1/search"
assert gateway_post["headers"]["Authorization"] == "Bearer test-token-abc123"
assert gateway_post["headers"]["Content-Type"] == "application/json"
assert gateway_post["json"]["queries"] == [{"use_case": "send email"}]
assert gateway_post["json"]["model"] == "test-model"
# ---------------------------------------------------------------------------
# Model auto-injection
# ---------------------------------------------------------------------------
class TestModelAutoInjection:
def test_injects_model_from_config(self, monkeypatch, gateway_post):
monkeypatch.setattr("tools.app_tools._get_current_model_name", lambda: "claude-sonnet-4")
from tools.app_tools import handle_app_search_tools
handle_app_search_tools({"queries": [{"use_case": "test"}]})
assert gateway_post["json"]["model"] == "claude-sonnet-4"
def test_omits_model_when_unresolvable(self, gateway_post):
from tools.app_tools import handle_app_search_tools
handle_app_search_tools({"queries": [{"use_case": "test"}]})
assert "model" not in gateway_post["json"]
# ---------------------------------------------------------------------------
# Gateway-internal param stripping (allowlist approach)
# ---------------------------------------------------------------------------
class TestExecuteStripsInternalParams:
def test_strips_sync_response_thought_step_metric(self, gateway_post):
from tools.app_tools import handle_app_execute_tools
handle_app_execute_tools({
"tools": [{"tool_slug": "TEST", "arguments": {}}],
"sync_response_to_workbench": True,
"thought": "testing",
"current_step": "TESTING",
"current_step_metric": "1/1 tests",
})
body = gateway_post["json"]
for key in ("sync_response_to_workbench", "thought", "current_step", "current_step_metric"):
assert key not in body
assert body["tools"] == [{"tool_slug": "TEST", "arguments": {}}]
# ---------------------------------------------------------------------------
# HTTP error → tool result (not exception)
# ---------------------------------------------------------------------------
class TestHttpErrorReturnedAsToolResult:
@pytest.mark.parametrize("status_code", [402, 403, 422, 500])
def test_returns_error_json_not_exception(self, monkeypatch, status_code):
monkeypatch.setattr("tools.app_tools.resolve_managed_tool_gateway", lambda v: _FAKE_GATEWAY)
error_body = {"error": {"code": "TEST_ERROR", "message": "fail"}}
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.json.return_value = error_body
resp.text = json.dumps(error_body)
monkeypatch.setattr(httpx.Client, "post", lambda self, url, **kw: resp)
from tools.app_tools import handle_app_search_tools
result = json.loads(handle_app_search_tools({"queries": [{"use_case": "test"}]}))
assert result["error"]["code"] == "TEST_ERROR"
# ---------------------------------------------------------------------------
# Network failure → tool result
# ---------------------------------------------------------------------------
class TestNetworkFailureReturnedAsToolResult:
def test_connect_error_returns_gateway_unreachable(self, monkeypatch):
monkeypatch.setattr("tools.app_tools.resolve_managed_tool_gateway", lambda v: _FAKE_GATEWAY)
def raise_connect(self, url, **kw):
raise httpx.ConnectError("Connection refused")
monkeypatch.setattr(httpx.Client, "post", raise_connect)
from tools.app_tools import handle_app_search_tools
result = json.loads(handle_app_search_tools({"queries": [{"use_case": "test"}]}))
assert result["error"]["code"] == "GATEWAY_UNREACHABLE"
def test_timeout_returns_gateway_timeout(self, monkeypatch):
monkeypatch.setattr("tools.app_tools.resolve_managed_tool_gateway", lambda v: _FAKE_GATEWAY)
def raise_timeout(self, url, **kw):
raise httpx.ReadTimeout("timed out")
monkeypatch.setattr(httpx.Client, "post", raise_timeout)
from tools.app_tools import handle_app_search_tools
result = json.loads(handle_app_search_tools({"queries": [{"use_case": "test"}]}))
assert result["error"]["code"] == "GATEWAY_TIMEOUT"
# ---------------------------------------------------------------------------
# Endpoint routing + payload forwarding
# ---------------------------------------------------------------------------
class TestEndpointRouting:
def test_manage_connections_forwards_toolkits(self, gateway_post):
from tools.app_tools import handle_app_manage_connections
handle_app_manage_connections({"toolkits": ["gmail", "slack"], "reinitiate_all": True})
assert gateway_post["url"].endswith("/v1/connections")
assert gateway_post["json"]["toolkits"] == ["gmail", "slack"]
assert gateway_post["json"]["reinitiate_all"] is True
def test_tool_schemas_forwards_slugs(self, gateway_post):
from tools.app_tools import handle_app_tool_schemas
handle_app_tool_schemas({"tool_slugs": ["GMAIL_SEND_EMAIL"], "include": ["input_schema", "output_schema"]})
assert gateway_post["url"].endswith("/v1/schemas")
assert gateway_post["json"]["tool_slugs"] == ["GMAIL_SEND_EMAIL"]
assert gateway_post["json"]["include"] == ["input_schema", "output_schema"]
# ---------------------------------------------------------------------------
# Registry entries
# ---------------------------------------------------------------------------
class TestRegistryEntries:
def test_all_four_tools_registered_under_app_tools(self):
from tools.registry import registry
import tools.app_tools # noqa: F401
expected = {"app_search_tools", "app_tool_schemas", "app_execute_tools", "app_manage_connections"}
for name in expected:
entry = registry._tools.get(name)
assert entry is not None, f"{name} not registered"
assert entry.toolset == "app_tools"
# ---------------------------------------------------------------------------
# session (object) vs session_id (string) asymmetry
# ---------------------------------------------------------------------------
class TestSessionHandling:
def test_search_uses_session_object(self, gateway_post):
from tools.app_tools import handle_app_search_tools
handle_app_search_tools({"queries": [{"use_case": "test"}], "session": {"generate_id": True}})
assert isinstance(gateway_post["json"]["session"], dict)
assert "session_id" not in gateway_post["json"]
def test_schemas_uses_session_id_string(self, gateway_post):
from tools.app_tools import handle_app_tool_schemas
handle_app_tool_schemas({"tool_slugs": ["TEST"], "session_id": "sess-123"})
assert gateway_post["json"]["session_id"] == "sess-123"
assert "session" not in gateway_post["json"]
def test_execute_uses_session_id_string(self, gateway_post):
from tools.app_tools import handle_app_execute_tools
handle_app_execute_tools({"tools": [{"tool_slug": "TEST", "arguments": {}}], "session_id": "sess-456"})
assert gateway_post["json"]["session_id"] == "sess-456"
assert "session" not in gateway_post["json"]
def test_connections_uses_session_id_string(self, gateway_post):
from tools.app_tools import handle_app_manage_connections
handle_app_manage_connections({"toolkits": ["gmail"], "session_id": "sess-789"})
assert gateway_post["json"]["session_id"] == "sess-789"
assert "session" not in gateway_post["json"]

View File

@@ -78,6 +78,63 @@ def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
assert result is None
def test_rewrite_localhost_origin_rewrites_subdomain():
rewrite = managed_tool_gateway._rewrite_localhost_origin
resolved, host = rewrite("http://tools-gateway.localhost:3009")
assert resolved == "http://127.0.0.1:3009"
assert host == "tools-gateway.localhost:3009"
def test_rewrite_localhost_origin_preserves_path():
rewrite = managed_tool_gateway._rewrite_localhost_origin
resolved, host = rewrite("http://tools-gateway.localhost:3009/v1/foo")
assert resolved == "http://127.0.0.1:3009/v1/foo"
assert host == "tools-gateway.localhost:3009"
def test_rewrite_localhost_origin_no_port():
rewrite = managed_tool_gateway._rewrite_localhost_origin
resolved, host = rewrite("http://tools-gateway.localhost")
assert resolved == "http://127.0.0.1"
assert host == "tools-gateway.localhost"
def test_rewrite_localhost_origin_ignores_bare_localhost():
rewrite = managed_tool_gateway._rewrite_localhost_origin
resolved, host = rewrite("http://localhost:3009")
assert resolved == "http://localhost:3009"
assert host is None
def test_rewrite_localhost_origin_ignores_real_domains():
rewrite = managed_tool_gateway._rewrite_localhost_origin
resolved, host = rewrite("https://tools-gateway.nousresearch.com")
assert resolved == "https://tools-gateway.nousresearch.com"
assert host is None
def test_gateway_config_resolved_origin_and_host_header():
cfg = managed_tool_gateway.ManagedToolGatewayConfig(
vendor="tools",
gateway_origin="http://tools-gateway.localhost:3009",
nous_user_token="tok",
managed_mode=True,
)
assert cfg.resolved_origin == "http://127.0.0.1:3009"
assert cfg.gateway_host_header == "tools-gateway.localhost:3009"
def test_gateway_config_resolved_origin_passthrough_for_real_domain():
cfg = managed_tool_gateway.ManagedToolGatewayConfig(
vendor="firecrawl",
gateway_origin="https://firecrawl-gateway.nousresearch.com",
nous_user_token="tok",
managed_mode=True,
)
assert cfg.resolved_origin == "https://firecrawl-gateway.nousresearch.com"
assert cfg.gateway_host_header is None
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))

438
tools/app_tools.py Normal file
View File

@@ -0,0 +1,438 @@
"""App integration tools — 500+ external apps via the Nous tool gateway.
Four meta tools that let the LLM discover, authenticate, and execute
real app tools at runtime through the Nous managed tool gateway.
Architecture:
Hermes → POST JSON → tools-gateway.nousresearch.com/v1/* → External APIs
Auth: Bearer <nous_user_token> (subscription-gated)
Vendor: "tools" in the managed gateway infra (build_vendor_gateway_url)
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, Optional
import httpx
from tools.registry import registry
from tools.managed_tool_gateway import (
is_managed_tool_gateway_ready,
resolve_managed_tool_gateway,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Timeouts per endpoint (connect, read)
# ---------------------------------------------------------------------------
_TIMEOUT_SEARCH = httpx.Timeout(30.0, connect=5.0)
_TIMEOUT_SCHEMAS = httpx.Timeout(15.0, connect=5.0)
_TIMEOUT_EXECUTE = httpx.Timeout(120.0, connect=5.0)
_TIMEOUT_CONNECTIONS = httpx.Timeout(30.0, connect=5.0)
# ---------------------------------------------------------------------------
# Module-level cached httpx client — avoids TCP+TLS setup per tool call.
# Follows the same thread-safe staleness pattern as image_generation_tool.py.
# ---------------------------------------------------------------------------
import threading
_http_client: Optional[httpx.Client] = None
_http_client_origin: Optional[str] = None
_http_client_lock = threading.Lock()
def _get_http_client(origin: str, verify: bool = True) -> httpx.Client:
"""Return a reusable httpx.Client, recreated when the origin changes."""
global _http_client, _http_client_origin
with _http_client_lock:
if _http_client is not None and _http_client_origin == origin:
return _http_client
if _http_client is not None:
try:
_http_client.close()
except Exception:
pass
_http_client = httpx.Client(verify=verify)
_http_client_origin = origin
return _http_client
# ---------------------------------------------------------------------------
# Config / availability helpers
# ---------------------------------------------------------------------------
def _read_portal_app_tools_enabled() -> bool:
"""Return True when the portal.app_tools config flag is on."""
from tools.tool_backend_helpers import portal_app_tools_enabled
return portal_app_tools_enabled()
def _app_tools_available() -> bool:
"""check_fn: True when subscription is active, gateway reachable, config on."""
if not _read_portal_app_tools_enabled():
return False
return is_managed_tool_gateway_ready("tools")
def _get_current_model_name() -> Optional[str]:
"""Best-effort read of the current model name from config.
Handles both ``"model": "name"`` and ``"model": {"default": "name"}``
config shapes. Returns None if unresolvable (caller should omit the
field rather than sending garbage).
"""
try:
from hermes_cli.config import load_config
config = load_config()
model_cfg = config.get("model")
if isinstance(model_cfg, str) and model_cfg.strip():
return model_cfg.strip()
if isinstance(model_cfg, dict):
default = model_cfg.get("default")
if isinstance(default, str) and default.strip():
return default.strip()
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Gateway HTTP client
# ---------------------------------------------------------------------------
def _gateway_post(
path: str,
payload: Dict[str, Any],
timeout: httpx.Timeout,
) -> Dict[str, Any]:
"""POST JSON to the tool gateway and return the parsed response.
Never raises — HTTP errors and network failures are returned as dicts
so the LLM can see them and communicate with the user.
"""
gateway = resolve_managed_tool_gateway("tools")
if gateway is None:
return {
"error": {
"code": "GATEWAY_UNAVAILABLE",
"message": "Nous tool gateway is not available. Check your subscription status.",
}
}
url = f"{gateway.gateway_origin.rstrip('/')}{path}"
headers = {
"Authorization": f"Bearer {gateway.nous_user_token}",
"Content-Type": "application/json",
}
try:
client = _get_http_client(url.split("/v1/")[0])
response = client.post(url, json=payload, headers=headers, timeout=timeout)
# Return parsed body regardless of status code — the LLM handles errors
try:
return response.json()
except Exception:
return {
"error": {
"code": f"HTTP_{response.status_code}",
"message": response.text[:2000],
}
}
except httpx.TimeoutException as exc:
return {
"error": {
"code": "GATEWAY_TIMEOUT",
"message": f"Request to {path} timed out: {exc}",
}
}
except Exception as exc:
return {
"error": {
"code": "GATEWAY_UNREACHABLE",
"message": f"Failed to reach tool gateway: {exc}",
}
}
# ---------------------------------------------------------------------------
# Tool handlers
# ---------------------------------------------------------------------------
def handle_app_search_tools(args: dict, **kw) -> str:
"""Search 500+ app integrations for tools matching a use case."""
payload: Dict[str, Any] = {}
queries = args.get("queries")
if queries:
payload["queries"] = queries
# session is an OBJECT {id, generate_id} — NOT a string
session = args.get("session")
if session is not None:
payload["session"] = session
# Auto-inject model name from config (omit if unresolvable)
model = args.get("model") or _get_current_model_name()
if model:
payload["model"] = model
return json.dumps(_gateway_post("/v1/search", payload, _TIMEOUT_SEARCH),
ensure_ascii=False, default=str)
def handle_app_tool_schemas(args: dict, **kw) -> str:
"""Get full input schemas for tools discovered via app_search_tools."""
payload: Dict[str, Any] = {}
tool_slugs = args.get("tool_slugs")
if tool_slugs:
payload["tool_slugs"] = tool_slugs
include = args.get("include")
if include:
payload["include"] = include
# session_id is a STRING — not an object
session_id = args.get("session_id")
if session_id is not None:
payload["session_id"] = session_id
return json.dumps(_gateway_post("/v1/schemas", payload, _TIMEOUT_SCHEMAS),
ensure_ascii=False, default=str)
def handle_app_execute_tools(args: dict, **kw) -> str:
"""Execute one or more app tools in parallel."""
payload: Dict[str, Any] = {}
tools = args.get("tools")
if tools:
payload["tools"] = tools
# session_id is a STRING
session_id = args.get("session_id")
if session_id is not None:
payload["session_id"] = session_id
# Strip gateway-internal params that are meaningless in Hermes
# (sync_response_to_workbench, thought, current_step, current_step_metric)
# They never enter the payload — we only pick the fields we need.
return json.dumps(_gateway_post("/v1/execute", payload, _TIMEOUT_EXECUTE),
ensure_ascii=False, default=str)
def handle_app_manage_connections(args: dict, **kw) -> str:
"""Check or initiate OAuth/API key connections for app toolkits."""
payload: Dict[str, Any] = {}
toolkits = args.get("toolkits")
if toolkits:
payload["toolkits"] = toolkits
reinitiate_all = args.get("reinitiate_all")
if reinitiate_all is not None:
payload["reinitiate_all"] = reinitiate_all
# session_id is a STRING
session_id = args.get("session_id")
if session_id is not None:
payload["session_id"] = session_id
return json.dumps(_gateway_post("/v1/connections", payload, _TIMEOUT_CONNECTIONS),
ensure_ascii=False, default=str)
# ---------------------------------------------------------------------------
# Tool registration
# ---------------------------------------------------------------------------
registry.register(
name="app_search_tools",
toolset="app_tools",
schema={
"name": "app_search_tools",
"description": (
"Search 500+ app integrations (Gmail, Slack, GitHub, Notion, Google Sheets, "
"Jira, Linear, Figma, and more) to find tools for a task. Returns tool slugs, "
"execution plans, pitfalls, and connection status."
),
"parameters": {
"type": "object",
"required": ["queries"],
"properties": {
"queries": {
"type": "array",
"minItems": 1,
"description": (
"Structured search queries. Split independent app actions "
"into separate queries. Each returns 4-6 tools."
),
"items": {
"type": "object",
"required": ["use_case"],
"properties": {
"use_case": {
"type": "string",
"maxLength": 1024,
"description": (
"Normalized description of the task. Include app "
"names if mentioned. Do NOT include personal "
"identifiers — put those in known_fields."
),
},
"known_fields": {
"type": "string",
"description": (
"Known inputs as comma-separated key:value pairs "
"(e.g. 'channel_name:general'). Omit if not relevant."
),
},
},
},
},
"session": {
"type": "object",
"description": "Session context. Pass {generate_id: true} for new workflows, {id: \"EXISTING\"} to continue.",
"properties": {
"id": {"type": "string", "description": "Existing session ID to reuse."},
"generate_id": {"type": "boolean", "description": "Set true for first call of a new workflow."},
},
},
},
},
},
handler=lambda args, **kw: handle_app_search_tools(args, **kw),
check_fn=_app_tools_available,
description="Search 500+ app integrations",
emoji="🔍",
)
registry.register(
name="app_tool_schemas",
toolset="app_tools",
schema={
"name": "app_tool_schemas",
"description": (
"Get full input parameter schemas for tools discovered via "
"app_search_tools. Only use slugs from search results — never invent."
),
"parameters": {
"type": "object",
"required": ["tool_slugs"],
"properties": {
"tool_slugs": {
"type": "array",
"description": "Tool slugs to retrieve schemas for.",
"items": {"type": "string", "minLength": 1},
},
"include": {
"type": "array",
"default": ["input_schema"],
"description": "Schema fields to include. Add 'output_schema' for response validation.",
"items": {"type": "string", "enum": ["input_schema", "output_schema"]},
},
"session_id": {
"type": "string",
"description": "Session ID from a prior app_search_tools call.",
},
},
},
},
handler=lambda args, **kw: handle_app_tool_schemas(args, **kw),
check_fn=_app_tools_available,
description="Get tool input schemas",
emoji="📋",
)
registry.register(
name="app_execute_tools",
toolset="app_tools",
schema={
"name": "app_execute_tools",
"description": (
"Execute one or more app tools in parallel (up to 50). "
"Requires active connection per toolkit. Use schema-compliant arguments only."
),
"parameters": {
"type": "object",
"required": ["tools"],
"properties": {
"tools": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"description": "Logically independent tools to execute in parallel.",
"items": {
"type": "object",
"required": ["tool_slug", "arguments"],
"additionalProperties": False,
"properties": {
"tool_slug": {
"type": "string",
"minLength": 1,
"description": "Tool slug from search results — never invent.",
},
"arguments": {
"type": "object",
"additionalProperties": True,
"description": "Arguments matching the tool's input schema exactly.",
},
},
},
},
"session_id": {
"type": "string",
"description": "Session ID from a prior app_search_tools call.",
},
},
},
},
handler=lambda args, **kw: handle_app_execute_tools(args, **kw),
check_fn=_app_tools_available,
max_result_size_chars=50_000,
description="Execute app tools",
emoji="",
)
registry.register(
name="app_manage_connections",
toolset="app_tools",
schema={
"name": "app_manage_connections",
"description": (
"Check or initiate OAuth/API key connections for app toolkits. "
"Returns auth links for inactive connections."
),
"parameters": {
"type": "object",
"required": ["toolkits"],
"properties": {
"toolkits": {
"type": "array",
"description": "Toolkit slugs to check or connect (e.g. ['gmail', 'slack']).",
"items": {"type": "string"},
},
"reinitiate_all": {
"type": "boolean",
"default": False,
"description": "Force reconnection even for active connections.",
},
"session_id": {
"type": "string",
"description": "Session ID from a prior app_search_tools call.",
},
},
},
},
handler=lambda args, **kw: handle_app_manage_connections(args, **kw),
check_fn=_app_tools_available,
description="Manage app connections",
emoji="🔗",
)

View File

@@ -60,7 +60,8 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
if gateway is None:
raise ValueError("Managed Modal requires a configured tool gateway and Nous user token")
self._gateway_origin = gateway.gateway_origin.rstrip("/")
self._gateway_origin = gateway.resolved_origin.rstrip("/")
self._gateway_host_header = gateway.gateway_host_header
self._nous_user_token = gateway.nous_user_token
self._task_id = task_id
self._persistent = persistent_filesystem
@@ -234,6 +235,8 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
"Authorization": f"Bearer {self._nous_user_token}",
"Content-Type": "application/json",
}
if self._gateway_host_header:
headers["Host"] = self._gateway_host_header
if extra_headers:
headers.update(extra_headers)

View File

@@ -362,7 +362,7 @@ def _get_managed_fal_client(managed_gateway):
global _managed_fal_client, _managed_fal_client_config
client_config = (
managed_gateway.gateway_origin.rstrip("/"),
managed_gateway.resolved_origin.rstrip("/"),
managed_gateway.nous_user_token,
)
with _managed_fal_client_lock:
@@ -375,7 +375,7 @@ def _get_managed_fal_client(managed_gateway):
_managed_fal_client = _ManagedFalSyncClient(
fal_client,
key=managed_gateway.nous_user_token,
queue_run_origin=managed_gateway.gateway_origin,
queue_run_origin=managed_gateway.resolved_origin,
)
_managed_fal_client_config = client_config
return _managed_fal_client

View File

@@ -7,7 +7,8 @@ import logging
import os
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Callable, Optional
from typing import Callable, Optional, Tuple
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
@@ -15,6 +16,27 @@ from hermes_constants import get_hermes_home
from tools.tool_backend_helpers import managed_nous_tools_enabled
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
def _rewrite_localhost_origin(origin: str) -> Tuple[str, Optional[str]]:
"""Rewrite ``*.localhost`` hostnames to ``127.0.0.1`` for DNS compatibility.
Python's :func:`socket.getaddrinfo` doesn't special-case ``*.localhost``
subdomains (RFC 6761), so ``tools-gateway.localhost`` fails DNS resolution
on most platforms. Bare ``localhost`` resolves fine and is left untouched.
Returns ``(resolved_origin, host_header_or_none)``.
"""
parsed = urlparse(origin)
hostname = parsed.hostname
if not hostname or not hostname.endswith(".localhost"):
return origin, None
port = parsed.port
netloc = f"127.0.0.1:{port}" if port else "127.0.0.1"
host_header = f"{hostname}:{port}" if port else hostname
resolved = urlunparse(parsed._replace(netloc=netloc))
return resolved, host_header
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@@ -26,6 +48,16 @@ class ManagedToolGatewayConfig:
nous_user_token: str
managed_mode: bool
@property
def resolved_origin(self) -> str:
"""Origin with ``*.localhost`` hostnames rewritten to ``127.0.0.1``."""
return _rewrite_localhost_origin(self.gateway_origin)[0]
@property
def gateway_host_header(self) -> Optional[str]:
"""Original ``host[:port]`` when the origin was rewritten, else ``None``."""
return _rewrite_localhost_origin(self.gateway_origin)[1]
def auth_json_path():
"""Return the Hermes auth store path, respecting HERMES_HOME overrides."""

View File

@@ -21,6 +21,11 @@ def managed_nous_tools_enabled() -> bool:
the free tier. We intentionally catch all exceptions and return
False — never block the agent startup path.
"""
import os
if os.getenv("TOOL_GATEWAY_USER_TOKEN", "").strip():
return True
try:
from hermes_cli.auth import get_nous_auth_status
@@ -123,6 +128,25 @@ def prefers_gateway(config_section: str) -> bool:
return False
def portal_app_tools_enabled() -> bool:
"""Return True when the portal.app_tools config flag is on.
Resolution: PORTAL_APP_TOOLS env var → config.yaml → default True.
Never raises — safe for check_fn and registration-time use.
"""
env_val = os.getenv("PORTAL_APP_TOOLS")
if env_val is not None:
return is_truthy_value(env_val)
try:
from hermes_cli.config import load_config
portal = (load_config() or {}).get("portal")
if isinstance(portal, dict):
return bool(portal.get("app_tools", True))
except Exception:
pass
return True
def fal_key_is_configured() -> bool:
"""Return True when FAL_KEY is set to a non-whitespace value.

View File

@@ -941,7 +941,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
f"{managed_gateway.resolved_origin.rstrip('/')}/", "v1"
)

View File

@@ -2048,7 +2048,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
f"{managed_gateway.resolved_origin.rstrip('/')}/", "v1"
)

View File

@@ -58,6 +58,8 @@ _HERMES_CORE_TOOLS = [
"cronjob",
# Cross-platform messaging (gated on gateway running via check_fn)
"send_message",
# App integrations (500+ apps via Nous tool gateway, gated via check_fn)
"app_search_tools", "app_tool_schemas", "app_execute_tools", "app_manage_connections",
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
# Kanban multi-agent coordination — only in schema when the agent is
@@ -239,6 +241,12 @@ TOOLSETS = {
"includes": []
},
"app_tools": {
"description": "External app integrations (Gmail, Slack, GitHub, Notion, 500+ apps) via Nous tool gateway",
"tools": ["app_search_tools", "app_tool_schemas", "app_execute_tools", "app_manage_connections"],
"includes": []
},
"kanban": {
"description": (
"Kanban multi-agent coordination — only active when the agent "