mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 12:18:44 +08:00
Compare commits
17 Commits
fix/parall
...
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",
|
||||
"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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ""),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
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:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user