mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 05:12:08 +08:00
Compare commits
17 Commits
feat/apify
...
sid/tool-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4f3ea196 | ||
|
|
cb12ee4b2d | ||
|
|
a57781f8a9 | ||
|
|
6749e335a3 | ||
|
|
53814b39c3 | ||
|
|
efd71e8914 | ||
|
|
bc2ba1356e | ||
|
|
e0b3fa6eb3 | ||
|
|
929245ba69 | ||
|
|
73a3de5798 | ||
|
|
3a26076194 | ||
|
|
04d3a2e2be | ||
|
|
70882abe9b | ||
|
|
2771d404a3 | ||
|
|
7150715e19 | ||
|
|
4eab358ff7 | ||
|
|
f96db81d3b |
@@ -1258,6 +1258,10 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
|||||||
"terminal",
|
"terminal",
|
||||||
"process",
|
"process",
|
||||||
"execute_code",
|
"execute_code",
|
||||||
|
"app_search_tools",
|
||||||
|
"app_tool_schemas",
|
||||||
|
"app_execute_tools",
|
||||||
|
"app_manage_connections",
|
||||||
}
|
}
|
||||||
|
|
||||||
if valid_names and not (valid_names & relevant_tool_names):
|
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 = [
|
lines = [
|
||||||
"# Nous Subscription",
|
"# 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:",
|
"Current capability status:",
|
||||||
]
|
]
|
||||||
lines.extend(_status_line(feature) for feature in features.items())
|
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)
|
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)
|
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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)
|
nous_subscription_prompt = _r.build_nous_subscription_prompt(agent.valid_tool_names)
|
||||||
if nous_subscription_prompt:
|
if nous_subscription_prompt:
|
||||||
stable_parts.append(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
|
# Tool-use enforcement: tells the model to actually call tools instead
|
||||||
# of describing intended actions. Controlled by config.yaml
|
# of describing intended actions. Controlled by config.yaml
|
||||||
# agent.tool_use_enforcement:
|
# agent.tool_use_enforcement:
|
||||||
|
|||||||
@@ -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 schema version - bump this when adding new required fields
|
||||||
"_config_version": 23,
|
"_config_version": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -2267,6 +2276,22 @@ OPTIONAL_ENV_VARS = {
|
|||||||
"category": "tool",
|
"category": "tool",
|
||||||
"advanced": True,
|
"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": {
|
"TAVILY_API_KEY": {
|
||||||
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
||||||
"prompt": "Tavily API key",
|
"prompt": "Tavily API key",
|
||||||
@@ -3301,7 +3326,7 @@ _KNOWN_ROOT_KEYS = {
|
|||||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||||
"agent", "terminal", "display", "compression", "delegation",
|
"agent", "terminal", "display", "compression", "delegation",
|
||||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||||
"sessions",
|
"sessions", "portal",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Valid fields inside a custom_providers list entry
|
# 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)}"
|
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:
|
if current_ver < latest_ver and not quiet:
|
||||||
print(f"Config version: {current_ver} → {latest_ver}")
|
print(f"Config version: {current_ver} → {latest_ver}")
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,12 @@ class NousSubscriptionFeatures:
|
|||||||
def modal(self) -> NousFeatureState:
|
def modal(self) -> NousFeatureState:
|
||||||
return self.features["modal"]
|
return self.features["modal"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_tools(self) -> NousFeatureState:
|
||||||
|
return self.features["app_tools"]
|
||||||
|
|
||||||
def items(self) -> Iterable[NousFeatureState]:
|
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:
|
for key in ordered:
|
||||||
yield self.features[key]
|
yield self.features[key]
|
||||||
|
|
||||||
@@ -225,6 +229,22 @@ def _resolve_browser_feature_state(
|
|||||||
return "local", available, active, False
|
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(
|
def get_nous_subscription_features(
|
||||||
config: Optional[Dict[str, object]] = None,
|
config: Optional[Dict[str, object]] = None,
|
||||||
) -> NousSubscriptionFeatures:
|
) -> 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_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_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")
|
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_state = resolve_modal_backend_state(
|
||||||
modal_mode,
|
modal_mode,
|
||||||
has_direct=direct_modal,
|
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",
|
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
|
||||||
explicit_configured=terminal_backend == "modal",
|
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(
|
return NousSubscriptionFeatures(
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ CONFIGURABLE_TOOLSETS = [
|
|||||||
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
||||||
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
||||||
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
|
("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.
|
# Toolsets that are OFF by default for new installs.
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"api_key": managed.nous_user_token,
|
"api_key": managed.nous_user_token,
|
||||||
"base_url": managed.gateway_origin.rstrip("/"),
|
"base_url": managed.resolved_origin.rstrip("/"),
|
||||||
"managed_mode": True,
|
"managed_mode": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ def _get_firecrawl_client() -> Any:
|
|||||||
|
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"api_key": managed_gateway.nous_user_token,
|
"api_key": managed_gateway.nous_user_token,
|
||||||
"api_url": managed_gateway.gateway_origin,
|
"api_url": managed_gateway.resolved_origin,
|
||||||
}
|
}
|
||||||
client_config = (
|
client_config = (
|
||||||
"tool-gateway",
|
"tool-gateway",
|
||||||
|
|||||||
@@ -444,6 +444,7 @@ class TestBuildNousSubscriptionPrompt:
|
|||||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
"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"),
|
"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"),
|
"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, ""),
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||||
"browser": NousFeatureState("browser", "Browser automation", 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, ""),
|
"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, ""),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
"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"),
|
"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"),
|
"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,
|
raising=False,
|
||||||
|
|||||||
246
tests/tools/test_app_tools.py
Normal file
246
tests/tools/test_app_tools.py
Normal 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"]
|
||||||
@@ -78,6 +78,63 @@ def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
|
|||||||
assert result is None
|
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):
|
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
||||||
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|||||||
438
tools/app_tools.py
Normal file
438
tools/app_tools.py
Normal 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="🔗",
|
||||||
|
)
|
||||||
@@ -60,7 +60,8 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
|||||||
if gateway is None:
|
if gateway is None:
|
||||||
raise ValueError("Managed Modal requires a configured tool gateway and Nous user token")
|
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._nous_user_token = gateway.nous_user_token
|
||||||
self._task_id = task_id
|
self._task_id = task_id
|
||||||
self._persistent = persistent_filesystem
|
self._persistent = persistent_filesystem
|
||||||
@@ -234,6 +235,8 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
|||||||
"Authorization": f"Bearer {self._nous_user_token}",
|
"Authorization": f"Bearer {self._nous_user_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
if self._gateway_host_header:
|
||||||
|
headers["Host"] = self._gateway_host_header
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
headers.update(extra_headers)
|
headers.update(extra_headers)
|
||||||
|
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ def _get_managed_fal_client(managed_gateway):
|
|||||||
global _managed_fal_client, _managed_fal_client_config
|
global _managed_fal_client, _managed_fal_client_config
|
||||||
|
|
||||||
client_config = (
|
client_config = (
|
||||||
managed_gateway.gateway_origin.rstrip("/"),
|
managed_gateway.resolved_origin.rstrip("/"),
|
||||||
managed_gateway.nous_user_token,
|
managed_gateway.nous_user_token,
|
||||||
)
|
)
|
||||||
with _managed_fal_client_lock:
|
with _managed_fal_client_lock:
|
||||||
@@ -375,7 +375,7 @@ def _get_managed_fal_client(managed_gateway):
|
|||||||
_managed_fal_client = _ManagedFalSyncClient(
|
_managed_fal_client = _ManagedFalSyncClient(
|
||||||
fal_client,
|
fal_client,
|
||||||
key=managed_gateway.nous_user_token,
|
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
|
_managed_fal_client_config = client_config
|
||||||
return _managed_fal_client
|
return _managed_fal_client
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from dataclasses import dataclass
|
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__)
|
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
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
|
_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"
|
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
|
||||||
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||||
|
|
||||||
@@ -26,6 +48,16 @@ class ManagedToolGatewayConfig:
|
|||||||
nous_user_token: str
|
nous_user_token: str
|
||||||
managed_mode: bool
|
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():
|
def auth_json_path():
|
||||||
"""Return the Hermes auth store path, respecting HERMES_HOME overrides."""
|
"""Return the Hermes auth store path, respecting HERMES_HOME overrides."""
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ def managed_nous_tools_enabled() -> bool:
|
|||||||
the free tier. We intentionally catch all exceptions and return
|
the free tier. We intentionally catch all exceptions and return
|
||||||
False — never block the agent startup path.
|
False — never block the agent startup path.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.getenv("TOOL_GATEWAY_USER_TOKEN", "").strip():
|
||||||
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli.auth import get_nous_auth_status
|
from hermes_cli.auth import get_nous_auth_status
|
||||||
|
|
||||||
@@ -123,6 +128,25 @@ def prefers_gateway(config_section: str) -> bool:
|
|||||||
return False
|
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:
|
def fal_key_is_configured() -> bool:
|
||||||
"""Return True when FAL_KEY is set to a non-whitespace value.
|
"""Return True when FAL_KEY is set to a non-whitespace value.
|
||||||
|
|
||||||
|
|||||||
@@ -941,7 +941,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
|||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
return managed_gateway.nous_user_token, urljoin(
|
return managed_gateway.nous_user_token, urljoin(
|
||||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
f"{managed_gateway.resolved_origin.rstrip('/')}/", "v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2048,7 +2048,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
|||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
return managed_gateway.nous_user_token, urljoin(
|
return managed_gateway.nous_user_token, urljoin(
|
||||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
f"{managed_gateway.resolved_origin.rstrip('/')}/", "v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ _HERMES_CORE_TOOLS = [
|
|||||||
"cronjob",
|
"cronjob",
|
||||||
# Cross-platform messaging (gated on gateway running via check_fn)
|
# Cross-platform messaging (gated on gateway running via check_fn)
|
||||||
"send_message",
|
"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)
|
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
|
||||||
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
|
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
|
||||||
# Kanban multi-agent coordination — only in schema when the agent is
|
# Kanban multi-agent coordination — only in schema when the agent is
|
||||||
@@ -239,6 +241,12 @@ TOOLSETS = {
|
|||||||
"includes": []
|
"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": {
|
"kanban": {
|
||||||
"description": (
|
"description": (
|
||||||
"Kanban multi-agent coordination — only active when the agent "
|
"Kanban multi-agent coordination — only active when the agent "
|
||||||
|
|||||||
Reference in New Issue
Block a user