From b9b100da112e150da9ac0c4e0338c940e04b1224 Mon Sep 17 00:00:00 2001 From: Julien Talbot Date: Wed, 15 Jul 2026 00:38:57 +0400 Subject: [PATCH] docs(xai): clarify x_search vs xurl routing without schema cross-refs Make the x_search / xurl boundary explicit in the skill, feature docs, toolset metadata, setup note, and reference pages, while keeping the model-facing x_search schema generic (no static xurl name). Regression tests assert behavioral routing invariants rather than frozen prose snapshots. Drop the stale CI-only plugin/hangup hunks already on main so this rebases cleanly. --- hermes_cli/tools_config.py | 4 +- skills/social-media/xurl/SKILL.md | 21 +++-- tests/skills/test_xurl_x_search_routing.py | 98 ++++++++++++++++++++ tests/test_toolsets.py | 9 ++ tests/tools/test_x_search_tool.py | 22 +++++ tools/x_search_tool.py | 9 +- toolsets.py | 8 +- website/docs/reference/tools-reference.md | 2 +- website/docs/reference/toolsets-reference.md | 2 +- website/docs/user-guide/features/x-search.md | 14 +++ 10 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 tests/skills/test_xurl_x_search_routing.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index f3abfb4d9a94..7dc4838596d9 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -475,7 +475,9 @@ TOOL_CATEGORIES = { "setup_title": "Select xAI Credential Source", "setup_note": ( "Hermes routes X searches through xAI's built-in x_search " - "Responses tool. Both credential sources hit the same " + "Responses tool for read-only public X discovery. Use the xurl " + "skill for authenticated X API reads and account actions. Both " + "credential sources hit the same " "https://api.x.ai/v1/responses endpoint — pick whichever you " "already have. SuperGrok OAuth is preferred when both are set " "(uses your subscription quota instead of API spend)." diff --git a/skills/social-media/xurl/SKILL.md b/skills/social-media/xurl/SKILL.md index ea3cda2f1d51..9033a21a7ddf 100644 --- a/skills/social-media/xurl/SKILL.md +++ b/skills/social-media/xurl/SKILL.md @@ -28,6 +28,14 @@ Use this skill for: - raw access to any X API v2 endpoint - multi-app / multi-account workflows +If Hermes also exposes the `x_search` tool, route by intent: + +- Use `x_search` for read-only public X discovery: "what are people saying", current reactions, public claims, broad semantic search, and synthesized answers with citations. +- Use `xurl` for exact or authenticated X API work: post, reply, quote, delete, like, repost, bookmark, follow, block, mute, DM, media upload, timeline, mentions, account-specific reads, or raw v2 endpoints. +- For mixed workflows, use `x_search` to discover candidate public posts, then use `xurl read` or another exact `xurl` command only after the target post/user/action is clear. +- Never treat an `x_search` answer as evidence that an X write happened. For state-changing X actions, only the `xurl` command output or the X API response proves the action. +- Prefer `x_search` over `xurl search` when the user asks for broad public discussion and does not need X API-exact results or authenticated account context. + This skill replaces the older `xitter` skill (which wrapped a third-party Python CLI). `xurl` is maintained by the X developer platform team, supports OAuth 2.0 PKCE with auto-refresh, and covers a substantially larger API surface. --- @@ -391,12 +399,13 @@ xurl --app staging /2/users/me # one-off against staging ## Agent Workflow 1. Verify prerequisites: `xurl --help` and `xurl auth status`. -2. **Check default app has credentials.** Parse the `auth status` output. The default app is marked with `▸`. If the default app shows `oauth2: (none)` but another app has a valid oauth2 user, tell the user to run `xurl auth default ` to fix it. This is the most common setup mistake — the user added an app with a custom name but never set it as default, so xurl keeps trying the empty `default` profile. -3. If auth is missing entirely, stop and direct the user to the "One-Time User Setup" section — do NOT attempt to register apps or pass secrets yourself. -4. Start with a cheap read (`xurl whoami`, `xurl user @handle`, `xurl search ... -n 3`) to confirm reachability. -5. Confirm the target post/user and the user's intent before any write action (post, reply, like, repost, DM, follow, block, delete). -6. Use JSON output directly — every response is already structured. -7. Never paste `~/.xurl` contents back into the conversation. +2. Before using `xurl search`, check intent. If the user needs broad public X discovery and the `x_search` tool is available, use `x_search` instead. Continue with `xurl` when the task needs an exact API read, authenticated account context, or any X write action. +3. **Check default app has credentials.** Parse the `auth status` output. The default app is marked with `▸`. If the default app shows `oauth2: (none)` but another app has a valid oauth2 user, tell the user to run `xurl auth default ` to fix it. This is the most common setup mistake — the user added an app with a custom name but never set it as default, so xurl keeps trying the empty `default` profile. +4. If auth is missing entirely, stop and direct the user to the "One-Time User Setup" section — do NOT attempt to register apps or pass secrets yourself. +5. Start with a cheap read (`xurl whoami`, `xurl user @handle`, `xurl search ... -n 3`) to confirm reachability. +6. Confirm the target post/user and the user's intent before any write action (post, reply, like, repost, DM, follow, block, delete). +7. Use JSON output directly — every response is already structured. +8. Never paste `~/.xurl` contents back into the conversation. --- diff --git a/tests/skills/test_xurl_x_search_routing.py b/tests/skills/test_xurl_x_search_routing.py new file mode 100644 index 000000000000..c8e9c7c685d8 --- /dev/null +++ b/tests/skills/test_xurl_x_search_routing.py @@ -0,0 +1,98 @@ +"""Behavioral contract for xurl / x_search routing guidance. + +These tests assert structural invariants (required topics + mutual exclusivity +of responsibility), not frozen prose snapshots. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +XURL_SKILL = REPO_ROOT / "skills" / "social-media" / "xurl" / "SKILL.md" +X_SEARCH_DOC = REPO_ROOT / "website" / "docs" / "user-guide" / "features" / "x-search.md" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _contains_any(text: str, *needles: str) -> bool: + lowered = text.lower() + return any(n.lower() in lowered for n in needles) + + +def test_xurl_skill_routes_by_intent_not_interchangeably(): + text = _read(XURL_SKILL) + lowered = text.lower() + + # Both surfaces named so agents can choose by capability. + assert "x_search" in lowered + assert "xurl" in lowered + + # x_search is discovery / read-only public research. + assert _contains_any(text, "read-only public", "public x discovery", "broad public") + # xurl owns authenticated / write / exact API work. + assert _contains_any( + text, + "authenticated", + "exact or authenticated", + "exact api", + "account actions", + "write action", + ) + # Writes must not be evidenced by x_search answers. + assert _contains_any( + text, + "never treat an `x_search` answer", + "never evidence", + "proves the action", + "x api response", + ) + # Prefer x_search over xurl search for broad public discovery when available. + assert "x_search" in lowered and "xurl search" in lowered + assert _contains_any(text, "prefer `x_search`", "use `x_search` instead", "route by intent") + + +def test_xurl_agent_workflow_prefers_x_search_for_broad_discovery(): + text = _read(XURL_SKILL) + # Workflow must preflight intent before xurl search. + assert "xurl search" in text.lower() + assert _contains_any(text, "check intent", "before using `xurl search`") + assert _contains_any(text, "broad public", "public x discovery") + assert _contains_any(text, "write action", "authenticated account", "exact api") + + +def test_x_search_doc_separates_discovery_from_account_actions(): + text = _read(X_SEARCH_DOC) + lowered = text.lower() + + assert "x_search" in lowered + assert "xurl" in lowered + # Explicit comparison section or equivalent boundary language. + assert _contains_any(text, "vs `xurl`", "vs xurl", "two different x surfaces") + assert _contains_any(text, "read-only public", "public x discovery") + assert _contains_any( + text, + "posting", + "replying", + "liking", + "dm", + "media upload", + "deleting", + ) + assert _contains_any( + text, + "authenticated", + "exact or authenticated", + "account actions", + "state-changing", + ) + # Write confirmation must come from xurl / X API, not x_search. + assert _contains_any( + text, + "confirmed by `xurl`", + "xurl` output", + "x api response", + "never evidence", + ) + assert _contains_any(text, "switch to the `xurl`", "switch to `xurl`", "xurl skill") diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 3f99554561ea..7f9c55a83bde 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -31,6 +31,15 @@ class TestGetToolset: assert ts is not None assert "web_search" in ts["tools"] + def test_x_search_toolset_marks_read_only_and_points_to_xurl(self): + ts = get_toolset("x_search") + assert ts is not None + assert ts["tools"] == ["x_search"] + description = ts["description"].lower() + assert "read-only" in description + assert "xurl" in description + assert "authenticated" in description + def test_merges_registry_tools_into_builtin_toolset(self, monkeypatch): reg = ToolRegistry() reg.register( diff --git a/tests/tools/test_x_search_tool.py b/tests/tools/test_x_search_tool.py index 3a5186f91424..385dd2cf2621 100644 --- a/tests/tools/test_x_search_tool.py +++ b/tests/tools/test_x_search_tool.py @@ -98,6 +98,28 @@ def test_x_search_rejects_conflicting_handle_filters(monkeypatch): assert result["error"] == "allowed_x_handles and excluded_x_handles cannot be used together" +def test_x_search_schema_is_read_only_without_cross_tool_names(): + """Static schema must state read-only scope without naming other surfaces. + + AGENTS.md forbids hardcoding cross-tool/skill names in tool schemas because + those surfaces may be unavailable. Keep out-of-scope guidance generic here; + xurl routing lives in the skill and feature docs. + """ + from tools.x_search_tool import X_SEARCH_SCHEMA + + description = X_SEARCH_SCHEMA["description"] + lowered = description.lower() + + assert "read-only" in lowered + assert "public x" in lowered + for action in ("post", "reply", "like", "dm", "upload media", "delete"): + assert action in lowered + assert "authenticated" in lowered + # No static cross-surface names in the model-facing schema. + assert "xurl" not in lowered + assert "web_search" not in lowered + + def test_x_search_extracts_inline_url_citations(monkeypatch): from tools.x_search_tool import x_search_tool diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py index 87de533dd4ac..8a89ead21ca2 100644 --- a/tools/x_search_tool.py +++ b/tools/x_search_tool.py @@ -480,9 +480,12 @@ X_SEARCH_SCHEMA = { "name": "x_search", "description": ( "Search X (Twitter) posts, profiles, and threads using xAI's built-in " - "X Search tool. Use this for current discussion, reactions, or claims " - "on X rather than general web pages. Available when xAI credentials " - "are configured (SuperGrok OAuth or XAI_API_KEY)." + "X Search tool. Read-only discovery only: use this for current " + "discussion, reactions, or claims on public X rather than general web " + "pages. Do not use it to post, reply, like, DM, upload media, delete, " + "or inspect the user's authenticated X account — those require a " + "separate authenticated X API surface outside this tool. Available " + "when xAI credentials are configured (SuperGrok OAuth or XAI_API_KEY)." ), "parameters": { "type": "object", diff --git a/toolsets.py b/toolsets.py index 5cd53fa7240e..75219f719cf7 100644 --- a/toolsets.py +++ b/toolsets.py @@ -110,9 +110,11 @@ TOOLSETS = { "x_search": { "description": ( "Search X (Twitter) posts and threads via xAI's built-in " - "x_search Responses tool. Available when xAI credentials are " - "configured (SuperGrok OAuth or XAI_API_KEY). Off by default; " - "enable in `hermes tools` → X (Twitter) Search." + "x_search Responses tool. Read-only public X discovery; use the " + "xurl skill for authenticated X API reads and account actions. " + "Available when xAI credentials are configured (SuperGrok OAuth " + "or XAI_API_KEY). Off by default; enable in `hermes tools` → " + "X (Twitter) Search." ), "tools": ["x_search"], "includes": [] diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index a8cf61fcf39c..54dc040de541 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -216,7 +216,7 @@ The single `video_generate` tool covers both modalities — pass `image_url` to | Tool | Description | Requires environment | |------|-------------|----------------------| -| `x_search` | Search X (Twitter) posts, profiles, and threads using xAI's built-in `x_search` Responses tool. Use this for current discussion, reactions, or claims on X rather than general web pages. Off by default — opt in via `hermes tools` → 🐦 X (Twitter) Search. Schema is only registered when xAI credentials are configured (check_fn-gated). | XAI_API_KEY **or** xAI Grok OAuth (SuperGrok / Premium+) login | +| `x_search` | Search X (Twitter) posts, profiles, and threads using xAI's built-in `x_search` Responses tool. Read-only public X discovery for current discussion, reactions, or claims on public X (not general web pages). Does not post, reply, like, DM, upload media, delete, or inspect the authenticated X account — those need a separate authenticated X API surface (e.g. the `xurl` skill). Off by default — opt in via `hermes tools` → 🐦 X (Twitter) Search. Schema is only registered when xAI credentials are configured (check_fn-gated). | XAI_API_KEY **or** xAI Grok OAuth (SuperGrok / Premium+) login | ## `tts` toolset diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 60d345e1e1fb..1f8092e7b43d 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -83,7 +83,7 @@ Or in-session: | `vision` | `vision_analyze` | Image analysis via vision-capable models. | | `video` | `video_analyze` | Video analysis and understanding tools (opt-in, not in the default toolset — add explicitly via `--toolsets`). | | `web` | `web_extract`, `web_search` | Web search and page content extraction. | -| `x_search` | `x_search` | Search X (Twitter) posts and threads via xAI's built-in `x_search` Responses tool. Off by default; opt in via `hermes tools`. Schema only registered when xAI credentials (SuperGrok OAuth or `XAI_API_KEY`) are configured. | +| `x_search` | `x_search` | Read-only public X discovery via xAI's built-in `x_search` Responses tool. Use the `xurl` skill for authenticated X API reads and account actions. Off by default; opt in via `hermes tools`. Schema only registered when xAI credentials (SuperGrok OAuth or `XAI_API_KEY`) are configured. | | `yuanbao` | `yb_query_group_info`, `yb_query_group_members`, `yb_search_sticker`, `yb_send_dm`, `yb_send_sticker` | Yuanbao DM/group actions and sticker search. Registered only on `hermes-yuanbao`. | ## Platform Toolsets diff --git a/website/docs/user-guide/features/x-search.md b/website/docs/user-guide/features/x-search.md index bdfeeeba095a..7b8271d9ae55 100644 --- a/website/docs/user-guide/features/x-search.md +++ b/website/docs/user-guide/features/x-search.md @@ -11,6 +11,17 @@ The `x_search` tool lets the agent search X (Twitter) posts, profiles, and threa **Use this instead of `web_search`** when you specifically want current discussion, reactions, or claims **on X**. For general web pages, keep using `web_search` / `web_extract`. +## `x_search` vs `xurl` + +Hermes can expose two different X surfaces: + +| Surface | Use it for | Do not use it for | +|---------|------------|-------------------| +| `x_search` | Read-only public X discovery: current discussion, reactions, claims, profiles, threads, and synthesized answers with citations. | Posting, replying, liking, DMs, media upload, deleting, or proving that an authenticated X account changed state. | +| `xurl` skill | Exact or authenticated X API work: `post`, `reply`, `read`, `like`, `dm`, timelines, mentions, media upload, account-specific reads, and raw v2 endpoints. | Broad Grok-synthesized public X research when `x_search` is available and no authenticated account context is needed. | + +For mixed workflows, use `x_search` to discover candidate public posts, then switch to `xurl read` or another exact `xurl` command after the target post/user/action is clear. Any state-changing X action must be confirmed by `xurl` output or the X API response; an `x_search` answer is never evidence that a write happened. + :::tip If you're paying Portal for an xAI model anyway, Live Search calls bill against the same xAI key configured for chat. See [Nous Portal](/integrations/nous-portal). ::: @@ -119,6 +130,8 @@ The agent will: 2. Get back a synthesized answer plus a list of citations linking to specific posts 3. Reply with the answer and references +If the next user request is "reply to the best one" or "like that post", the agent should switch to the `xurl` skill, confirm the exact target post, and use the X API action. `x_search` remains a discovery tool. + ## Troubleshooting ### "No xAI credentials available" @@ -149,5 +162,6 @@ Causes worth checking: ## See Also - [xAI Grok OAuth (SuperGrok / Premium+)](../../guides/xai-grok-oauth.md) — the OAuth setup guide +- [xurl skill](../skills/bundled/social-media/social-media-xurl.md) — official X API CLI for authenticated account actions - [Web Search & Extract](web-search.md) — for general (non-X) web search - [Tools Reference](../../reference/tools-reference.md) — full tool catalog