From 101987837f8a9553292a97b6bf8dcf5bafc7b17b Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 12:07:14 -0700 Subject: [PATCH 01/16] docs(nebius_search): add design spec for Nebius Agentic Search node Self-contained agentic search node mirroring search_exa: a Nebius Token Factory LLM runs a bounded native-function-calling loop over an internal Tavily web-search tool and returns a cited answer. Co-Authored-By: Claude Opus 4.8 --- ...06-01-nebius-agentic-search-node-design.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md diff --git a/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md b/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md new file mode 100644 index 000000000..2dcd75fff --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md @@ -0,0 +1,177 @@ +# Nebius Agentic Search Node — Design Spec + +**Date:** 2026-06-01 +**Author:** Po-Hsu (pohsu.lien@rocketride.ai) +**Status:** Draft — pending review +**Related:** GTM-driven new nodes list (Nebius sponsorship, June 18 event). No GitHub issue yet. + +--- + +## 1. Goal + +Add a single, self-contained pipeline node — **Nebius Agentic Search** — that performs +*multi-step agentic web search*: a Nebius-hosted LLM reasons about a user question, +decides what to search, calls Tavily web search one or more times (refining queries as +needed), and synthesizes a grounded, cited answer. + +This showcases the full Nebius stack in one drag-and-drop node: +**Nebius Token Factory (reasoning) + Tavily (real-time search)** — Tavily being the +agentic-search capability Nebius acquired. + +## 2. Background / Facts + +- **Nebius Token Factory** (formerly AI Studio): OpenAI-compatible inference API at + `https://api.tokenfactory.nebius.com/v1/`, Bearer auth. Supports native + OpenAI-style tool/function calling (`tools=`, `tool_choice=`, `message.tool_calls`). +- **Tavily**: web search + content extraction. `POST https://api.tavily.com/search`, + Bearer `tvly-...`. Response: `{ query, answer?, results:[{title,url,content,score}], ... }`. +- This is the agentic (multi-step loop) variant. A single-shot "search-then-answer" + was considered and rejected: the deliverable must visibly demonstrate *agentic* + behavior for the GTM message. + +## 3. Architecture + +**Shape: self-contained node mirroring `search_exa`, NOT subclassing `AgentBase`.** + +Why not `AgentBase`: `AgentHostServices.LLM.__init__` +(`packages/ai/src/ai/common/agent/_internal/host.py:27-37`) hard-requires exactly one +*externally wired* LLM node, and `AgentBase.call_llm` routes through the engine seam +which strips the `tools=` parameter (forcing the JSON-envelope workaround that +`agent_deepagent` had to invent). A self-contained node lets us: +- bundle the Nebius LLM internally (true single-node experience), and +- use **native** Nebius function calling (`response.choices[0].message.tool_calls`) — + a cleaner, more reliable loop than the JSON-envelope protocol. + +The node reuses the proven `search_exa` lifecycle: a `ChatBase` subclass whose +`chat(question) -> Answer` runs the agentic loop internally. `IGlobal` resolves config ++ API keys and constructs the backend; `IInstance` forwards questions to it. + +### Component map + +| File | Responsibility | Mirrors | +| --- | --- | --- | +| `nodes/src/nodes/nebius_search/__init__.py` | Export `IGlobal`, `IInstance` | `search_exa/__init__.py` | +| `nodes/src/nodes/nebius_search/IGlobal.py` | Resolve config + both API keys; build backend in `beginGlobal()`; `validateConfig()` warns | `search_exa/IGlobal.py` | +| `nodes/src/nodes/nebius_search/IInstance.py` | `writeQuestions` → `IGlobal.search.chat(question)` → `writeAnswers` | `search_exa/IInstance.py` | +| `nodes/src/nodes/nebius_search/nebius_search.py` | `NebiusAgenticSearch(ChatBase)`: the agentic loop, Nebius client, Tavily tool | `search_exa/exa_search.py` + `tool_exa_search` HTTP/retry | +| `nodes/src/nodes/nebius_search/services.json` | Node definition: classType, lanes, profiles, fields, test | `search_exa/services.json` | +| `nodes/src/nodes/nebius_search/requirements.txt` | `openai`, `requests` | — | +| `nodes/src/nodes/nebius_search/nebius.svg` | Icon | — | +| `nodes/src/nodes/nebius_search/README.md` | Usage docs | `search_exa/README.md` | + +Provider/protocol name: **`nebius_search`** (broad enough to add facets later; +see §8). `classType: ["search"]`, `capabilities: ["invoke"]`, `register: "filter"`. + +## 4. Data flow + +``` +questions lane ──▶ NebiusAgenticSearch.chat(question) + │ + ▼ + ┌── agentic loop (bounded) ───────────────────────┐ + │ 1. Nebius LLM call with tools=[tavily_search] │ + │ 2. if message.tool_calls: │ + │ for each call → Tavily HTTP → append result │ + │ as a tool-role message → goto 1 │ + │ 3. else: final answer text │ + └──────────────────────────────────────────────────┘ + │ + ▼ + answers lane : synthesized answer (string) + documents lane: cited sources [{title, url, content, score}] +``` + +`lanes: { "questions": ["answers", "documents"] }`. +The `answers` lane carries the synthesized answer; `documents` carries the +deduplicated source list accumulated across all Tavily calls (so downstream nodes can +render citations). + +## 5. The agentic loop (`nebius_search.py`) + +- Build an `openai.OpenAI(api_key=, base_url="https://api.tokenfactory.nebius.com/v1/")` + client (pattern precedent: `llm_perplexity` uses `langchain_openai.ChatOpenAI` with a + custom base_url; here we use the raw `openai` client to get native tool_calls). +- Define one tool exposed to the model: + `tavily_search(query, search_depth?, max_results?, topic?, time_range?, include_domains?)` + — schema mirrors `tool_exa_search`'s `@tool_function` input schema, adapted to Tavily. +- Loop, capped at `maxIterations` (config, default 5): + 1. `client.chat.completions.create(model, messages, tools=[...], tool_choice="auto")` + 2. If `choice.message.tool_calls`: execute each via the Tavily HTTP helper, append a + `{"role":"tool","tool_call_id":...,"content": }` message, accumulate sources, + continue. + 3. Else: return `choice.message.content` as the answer. +- If the cap is hit before a final answer: make one final no-tools call forcing a + best-effort answer from gathered context (never loop forever). +- System prompt instructs the model: search when facts are needed, refine queries on + weak results, cite sources, stop when confident. + +### Tavily HTTP helper +Mirror `tool_exa_search._request_with_retry` (`nodes/src/nodes/tool_exa_search/IInstance.py:210-257`): +`POST https://api.tavily.com/search`, Bearer auth, 30s timeout, exponential-backoff +retry on 429 / 5xx. Reuse `search_exa`'s `_validate_public_url` SSRF guard +(`search_exa/exa_search.py:146-168`) on returned URLs. + +## 6. Configuration (`services.json`) + +- `preconfig.profiles.default`: `{ model: "", maxIterations: 5, + searchDepth: "advanced", maxResults: 5 }`. **Open item:** confirm the exact default + model slug from the Token Factory model list before finalizing. +- Fields: + - `nebius_search.apikey` — Nebius API key. `secure: true`, `ApiKeyWidget`. + Resolution order: node config → connConfig → `os.environ["ROCKETRIDE_NEBIUS_KEY"]`. + - `nebius_search.tavilyApikey` — Tavily API key. `secure: true`, `ApiKeyWidget`. + Resolution order: node config → connConfig → `os.environ["ROCKETRIDE_TAVILY_KEY"]`. + - `nebius_search.model` — Nebius model id (string). + - `nebius_search.maxIterations` — integer 1–10, default 5. + - `nebius_search.searchDepth` — enum `basic|advanced`, default `advanced`. + - `nebius_search.maxResults` — integer 1–20, default 5. +- `tile`: show model + maxIterations. +- `shape`: one "Pipe" section grouping the two API keys + profile/params. + +## 7. Error handling + +| Condition | Behavior | +| --- | --- | +| Missing Nebius or Tavily key | `IGlobal.beginGlobal()` raises a node-specific error; `validateConfig()` emits a warning (matches `search_exa`). | +| Nebius 401 / Tavily 401 | Raise `PermissionError` with provider-prefixed message. | +| 429 / 5xx | Exponential-backoff retry (Tavily helper); surface a clear error after retries. | +| Timeout / connection error | Map to `TimeoutError` / `ConnectionError`. | +| Model emits malformed tool args | Skip that tool call, append an error tool-message so the model can recover; do not crash. | +| `maxIterations` reached | One final no-tools answer attempt; never infinite-loop. | +| Empty question | Raise `ValueError` (matches `search_exa`). | +| SSRF (private/loopback URLs in results) | Drop via `_validate_public_url`. | + +## 8. Forward compatibility ("leave room") + +Per manager guidance — design so future facets attach without a rewrite: +- Broad provider name `nebius_search`; profile-based config (same extensible pattern as + `llm_perplexity`). +- Loop, Tavily client, and Nebius client are separate units in `nebius_search.py`, so a + future "pure Nebius chat" or "embeddings" facet can reuse the client wiring. +- Optionally (not now / YAGNI): expose the loop as a `@tool_function` and add + `"tool"` to `classType` so a parent agent can call Nebius Agentic Search as a tool — + mirrors `agent_deepagent/deepagent_agent/IInstance.py:53-90`. + +## 9. Testing + +- `services.json` `test` block (per `docs/README-node-testing.md`), gated with + `requires: ["ROCKETRIDE_NEBIUS_KEY", "ROCKETRIDE_TAVILY_KEY"]` for full runs, plus a + mock path via `ROCKETRIDE_MOCK` (mocks in `nodes/test/mocks/`) so CI runs without keys. +- Cases: + 1. Simple factual question → `answers` notEmpty + `documents` notEmpty (≥1 source). + 2. Multi-hop question (forces ≥2 searches under mock) → answer references both sources. + 3. Empty input → error / graceful handling. +- Contract test (`builder nodes:test`) validates `services.json` structure + module import. +- Pure helpers (query extraction, source dedup, URL validation) unit-tested directly. + +## 10. Dependencies + +`openai`, `requests` (both already used elsewhere in the repo). Synchronous; no +homomorphic crypto, no Python-3.11 constraint, no async↔sync bridge. + +## 11. Open items (confirm before/within implementation) + +1. Default Token Factory model slug (verify against Nebius model list). +2. Whether `documents` lane sources should also include Tavily's own `answer` field. +3. Phasing: this spec targets the production agentic node directly (per decision to + build Shape 2 / option 2). GTM = production node; June 18 = polish. From eebc900485b3a20d71fb4aa58c747b4881462f1c Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 12:37:16 -0700 Subject: [PATCH 02/16] docs(nebius_search): rewrite spec to compose-existing-infra approach (Shape B) Build Nebius Agentic Search by composing the engine's existing agent pattern: new tool_tavily_search + new llm_nebius (Token Factory) wired into the existing agent_deepagent loop, plus a pipeline template. Chosen over a self-contained bundled-LLM node because every agent in the repo is built this way and it reuses a battle-tested loop. Co-Authored-By: Claude Opus 4.8 --- ...06-01-nebius-agentic-search-node-design.md | 308 +++++++++--------- 1 file changed, 151 insertions(+), 157 deletions(-) diff --git a/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md b/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md index 2dcd75fff..03ff2c24c 100644 --- a/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md +++ b/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md @@ -1,4 +1,4 @@ -# Nebius Agentic Search Node — Design Spec +# Nebius Agentic Search — Design Spec **Date:** 2026-06-01 **Author:** Po-Hsu (pohsu.lien@rocketride.ai) @@ -9,169 +9,163 @@ ## 1. Goal -Add a single, self-contained pipeline node — **Nebius Agentic Search** — that performs -*multi-step agentic web search*: a Nebius-hosted LLM reasons about a user question, -decides what to search, calls Tavily web search one or more times (refining queries as -needed), and synthesizes a grounded, cited answer. - -This showcases the full Nebius stack in one drag-and-drop node: -**Nebius Token Factory (reasoning) + Tavily (real-time search)** — Tavily being the -agentic-search capability Nebius acquired. - -## 2. Background / Facts - -- **Nebius Token Factory** (formerly AI Studio): OpenAI-compatible inference API at - `https://api.tokenfactory.nebius.com/v1/`, Bearer auth. Supports native - OpenAI-style tool/function calling (`tools=`, `tool_choice=`, `message.tool_calls`). -- **Tavily**: web search + content extraction. `POST https://api.tavily.com/search`, - Bearer `tvly-...`. Response: `{ query, answer?, results:[{title,url,content,score}], ... }`. -- This is the agentic (multi-step loop) variant. A single-shot "search-then-answer" - was considered and rejected: the deliverable must visibly demonstrate *agentic* - behavior for the GTM message. - -## 3. Architecture - -**Shape: self-contained node mirroring `search_exa`, NOT subclassing `AgentBase`.** - -Why not `AgentBase`: `AgentHostServices.LLM.__init__` -(`packages/ai/src/ai/common/agent/_internal/host.py:27-37`) hard-requires exactly one -*externally wired* LLM node, and `AgentBase.call_llm` routes through the engine seam -which strips the `tools=` parameter (forcing the JSON-envelope workaround that -`agent_deepagent` had to invent). A self-contained node lets us: -- bundle the Nebius LLM internally (true single-node experience), and -- use **native** Nebius function calling (`response.choices[0].message.tool_calls`) — - a cleaner, more reliable loop than the JSON-envelope protocol. - -The node reuses the proven `search_exa` lifecycle: a `ChatBase` subclass whose -`chat(question) -> Answer` runs the agentic loop internally. `IGlobal` resolves config -+ API keys and constructs the backend; `IInstance` forwards questions to it. - -### Component map - -| File | Responsibility | Mirrors | -| --- | --- | --- | -| `nodes/src/nodes/nebius_search/__init__.py` | Export `IGlobal`, `IInstance` | `search_exa/__init__.py` | -| `nodes/src/nodes/nebius_search/IGlobal.py` | Resolve config + both API keys; build backend in `beginGlobal()`; `validateConfig()` warns | `search_exa/IGlobal.py` | -| `nodes/src/nodes/nebius_search/IInstance.py` | `writeQuestions` → `IGlobal.search.chat(question)` → `writeAnswers` | `search_exa/IInstance.py` | -| `nodes/src/nodes/nebius_search/nebius_search.py` | `NebiusAgenticSearch(ChatBase)`: the agentic loop, Nebius client, Tavily tool | `search_exa/exa_search.py` + `tool_exa_search` HTTP/retry | -| `nodes/src/nodes/nebius_search/services.json` | Node definition: classType, lanes, profiles, fields, test | `search_exa/services.json` | -| `nodes/src/nodes/nebius_search/requirements.txt` | `openai`, `requests` | — | -| `nodes/src/nodes/nebius_search/nebius.svg` | Icon | — | -| `nodes/src/nodes/nebius_search/README.md` | Usage docs | `search_exa/README.md` | - -Provider/protocol name: **`nebius_search`** (broad enough to add facets later; -see §8). `classType: ["search"]`, `capabilities: ["invoke"]`, `register: "filter"`. - -## 4. Data flow +Deliver a **Nebius Agentic Search** capability: a pipeline where a Nebius-hosted LLM +reasons about a user question, decides what to search, calls Tavily web search one or +more times (refining queries as needed), and synthesizes a grounded, cited answer. + +This showcases the full Nebius stack — **Nebius Token Factory (reasoning) + Tavily +(real-time search)** — where Tavily is the agentic-search capability Nebius acquired. + +## 2. Approach: compose existing infrastructure (Shape B) + +Decision: build the feature the way every existing agent in this codebase is built — +an **agent node driving a wired LLM channel + wired tool channel** — rather than a +self-contained node with a bundled LLM and a hand-rolled tool loop. + +**Evidence for this choice:** all four agent nodes (`agent_crewai`, `agent_deepagent`, +`agent_langchain`, `agent_rocketride`) are `classType: ["agent","tool"]` and require an +external `"llm"` invoke channel; `llm_*` nodes are single-purpose inference providers; +`tool_*` nodes are single-purpose tools; `search_exa` is single-shot with no loop. There +is **zero precedent** for a node that bundles its own LLM client and runs an internal +tool-calling loop. Composing existing infra reuses a battle-tested loop and matches the +engine's deliberate architecture (text-only LLM seam + JSON-envelope tool protocol — +documented in `agent_langchain/README.md` and `agent_deepagent/deepagent.py`). + +## 3. Background / Facts + +- **Nebius Token Factory** (formerly AI Studio): OpenAI-compatible inference at + `https://api.tokenfactory.nebius.com/v1/`, Bearer auth. Hosts 60+ open models + (Llama 3.x, Qwen3/2.5, DeepSeek V3, GPT-OSS, Mistral). "Using Nebius" = using this + inference service; Nebius does not train a proprietary foundation model. +- **Tavily** (Nebius-acquired): "the web access layer for agents" — `POST + https://api.tavily.com/search`, Bearer `tvly-...`. Response: + `{ query, results:[{title,url,content,score}], ... }`. +- **"Agentic search" definition** (both vendors): the *search/retrieval layer that + agents call*, distinct from the reasoning LLM. The agency comes from the LLM driving + the search — which is exactly what the agent loop provides. + +## 4. Deliverables + +| # | Item | New/Reuse | Mirrors | +| --- | --- | --- | --- | +| 1 | `tool_tavily_search` node | **NEW** (core work) | `tool_exa_search` (Exa → Tavily) | +| 2 | `llm_nebius` node | **NEW** (branding) | `llm_gmi_cloud` (base_url → Token Factory) | +| 3 | Agentic loop | **REUSE** `agent_deepagent` (default) | — | +| 4 | "Nebius Agentic Search" pipeline template | **NEW** | `examples/*.pipe`, `canvas/templates/templates.json` | + +`llm_nebius` is recommended for the on-brand "Nebius" palette entry, but is optional — +the existing `llm_openai_api` node already accepts a custom `base_url` +(`llm_openai_api/services.json:99`) and can point at Token Factory as a zero-new-node +fallback. + +### 4.1 `tool_tavily_search` (new tool node) + +Clone of `tool_exa_search`. Files: `__init__.py`, `IGlobal.py`, `IInstance.py`, +`services.json`, `requirements.txt` (`requests`), `tavily.svg`, `README.md`. + +- `classType: ["tool"]`, `capabilities: ["invoke"]`, `register: "filter"`, + `lanes: {}` (discovered via the control-plane invoke seam, like `tool_exa_search`). +- One `@tool_function`: + `tavily_search(query, search_depth?, max_results?, topic?, time_range?, + include_domains?, exclude_domains?)` — input schema mirrors `tool_exa_search` + adapted to Tavily params. Output: `{ success, query, num_results, results:[{title, + url, content, score, published_date?}], error? }`. +- Implementation: `POST https://api.tavily.com/search`, Bearer auth, 30s timeout, + exponential-backoff retry on 429/5xx (clone `tool_exa_search/IInstance.py:210-257`), + SSRF guard on result URLs (clone `search_exa/exa_search.py:146-168`). +- API key: `secure`, `ApiKeyWidget`. Resolution: node config → connConfig → + `os.environ["ROCKETRIDE_TAVILY_KEY"]`. + +### 4.2 `llm_nebius` (new LLM provider node) + +Clone of `llm_gmi_cloud`. Files: `__init__.py` (exports `getChat`), `IGlobal.py`, +`IInstance.py`, `nebius.py` (`Chat(ChatBase)`), `services.json`, `requirements.txt` +(`langchain-openai`), `nebius.svg`, `README.md`. + +- `classType: ["llm"]`, used by agents via the `llm` invoke channel. +- `Chat(ChatBase)` builds + `langchain_openai.ChatOpenAI(model=, base_url="https://api.tokenfactory.nebius.com/v1/", + api_key=, max_tokens=...)` — same shape as `llm_gmi_cloud/gmi_cloud.py:58-63`. +- Default model: **`meta-llama/Llama-3.3-70B-Instruct`** (strong tool-calling). **Open + item:** confirm exact Token Factory slug + that the agent loop works against it. +- API key: `secure`, `ApiKeyWidget`. Resolution: node config → connConfig → + `os.environ["ROCKETRIDE_NEBIUS_KEY"]`. + +### 4.3 Reuse `agent_deepagent` + +Drives the reasoning/tool loop. `classType: ["agent","tool"]`, invoke channels +`llm: {min 1}`, `tool: {min 0}` (`agent_deepagent/services.agent.json:13-24`). No +changes required. (`agent_rocketride` "Wave" is a viable alternative driver — it batches +parallel tool calls, useful for fan-out multi-source search — but `agent_deepagent` is +the default for simplicity.) + +### 4.4 Pipeline template + +A `.pipe` example (e.g. `examples/nebius-agentic-search.pipe`) wiring +`llm_nebius` (llm channel) + `tool_tavily_search` (tool channel) + `agent_deepagent`, +with a question/answer endpoint. Optionally register it in +`packages/shared-ui/src/components/canvas/templates/templates.json` so it appears as a +one-click "Nebius Agentic Search" template in the canvas. + +## 5. Data flow ``` -questions lane ──▶ NebiusAgenticSearch.chat(question) - │ - ▼ - ┌── agentic loop (bounded) ───────────────────────┐ - │ 1. Nebius LLM call with tools=[tavily_search] │ - │ 2. if message.tool_calls: │ - │ for each call → Tavily HTTP → append result │ - │ as a tool-role message → goto 1 │ - │ 3. else: final answer text │ - └──────────────────────────────────────────────────┘ - │ - ▼ - answers lane : synthesized answer (string) - documents lane: cited sources [{title, url, content, score}] +question ─▶ agent_deepagent (questions lane) + │ LangGraph loop (existing): + │ ├─ call host LLM ──▶ llm_nebius ──▶ Token Factory (Nebius reasoning) + │ ├─ JSON-envelope tool_call ──▶ tool_tavily_search ──▶ api.tavily.com + │ └─ feed result back, repeat until {"type":"final"} + ▼ + answers lane: synthesized, cited answer ``` -`lanes: { "questions": ["answers", "documents"] }`. -The `answers` lane carries the synthesized answer; `documents` carries the -deduplicated source list accumulated across all Tavily calls (so downstream nodes can -render citations). - -## 5. The agentic loop (`nebius_search.py`) - -- Build an `openai.OpenAI(api_key=, base_url="https://api.tokenfactory.nebius.com/v1/")` - client (pattern precedent: `llm_perplexity` uses `langchain_openai.ChatOpenAI` with a - custom base_url; here we use the raw `openai` client to get native tool_calls). -- Define one tool exposed to the model: - `tavily_search(query, search_depth?, max_results?, topic?, time_range?, include_domains?)` - — schema mirrors `tool_exa_search`'s `@tool_function` input schema, adapted to Tavily. -- Loop, capped at `maxIterations` (config, default 5): - 1. `client.chat.completions.create(model, messages, tools=[...], tool_choice="auto")` - 2. If `choice.message.tool_calls`: execute each via the Tavily HTTP helper, append a - `{"role":"tool","tool_call_id":...,"content": }` message, accumulate sources, - continue. - 3. Else: return `choice.message.content` as the answer. -- If the cap is hit before a final answer: make one final no-tools call forcing a - best-effort answer from gathered context (never loop forever). -- System prompt instructs the model: search when facts are needed, refine queries on - weak results, cite sources, stop when confident. - -### Tavily HTTP helper -Mirror `tool_exa_search._request_with_retry` (`nodes/src/nodes/tool_exa_search/IInstance.py:210-257`): -`POST https://api.tavily.com/search`, Bearer auth, 30s timeout, exponential-backoff -retry on 429 / 5xx. Reuse `search_exa`'s `_validate_public_url` SSRF guard -(`search_exa/exa_search.py:146-168`) on returned URLs. - -## 6. Configuration (`services.json`) - -- `preconfig.profiles.default`: `{ model: "", maxIterations: 5, - searchDepth: "advanced", maxResults: 5 }`. **Open item:** confirm the exact default - model slug from the Token Factory model list before finalizing. -- Fields: - - `nebius_search.apikey` — Nebius API key. `secure: true`, `ApiKeyWidget`. - Resolution order: node config → connConfig → `os.environ["ROCKETRIDE_NEBIUS_KEY"]`. - - `nebius_search.tavilyApikey` — Tavily API key. `secure: true`, `ApiKeyWidget`. - Resolution order: node config → connConfig → `os.environ["ROCKETRIDE_TAVILY_KEY"]`. - - `nebius_search.model` — Nebius model id (string). - - `nebius_search.maxIterations` — integer 1–10, default 5. - - `nebius_search.searchDepth` — enum `basic|advanced`, default `advanced`. - - `nebius_search.maxResults` — integer 1–20, default 5. -- `tile`: show model + maxIterations. -- `shape`: one "Pipe" section grouping the two API keys + profile/params. - -## 7. Error handling +The loop, host-LLM routing, tool discovery/invocation, and SSE events are all provided +by the existing `agent_deepagent` driver (`deepagent.py`) + `AgentBase` +(`packages/ai/src/ai/common/agent/agent.py`). No loop code is written by us. + +## 6. Error handling + +Most behavior is inherited from the existing agent loop. New-code responsibilities: | Condition | Behavior | | --- | --- | -| Missing Nebius or Tavily key | `IGlobal.beginGlobal()` raises a node-specific error; `validateConfig()` emits a warning (matches `search_exa`). | -| Nebius 401 / Tavily 401 | Raise `PermissionError` with provider-prefixed message. | -| 429 / 5xx | Exponential-backoff retry (Tavily helper); surface a clear error after retries. | -| Timeout / connection error | Map to `TimeoutError` / `ConnectionError`. | -| Model emits malformed tool args | Skip that tool call, append an error tool-message so the model can recover; do not crash. | -| `maxIterations` reached | One final no-tools answer attempt; never infinite-loop. | -| Empty question | Raise `ValueError` (matches `search_exa`). | -| SSRF (private/loopback URLs in results) | Drop via `_validate_public_url`. | +| Missing Tavily / Nebius key | `IGlobal.beginGlobal()` raises node-specific error; `validateConfig()` warns (matches `tool_exa_search`/`llm_gmi_cloud`). | +| Tavily 401 / Nebius 401 | Provider-prefixed `PermissionError`. | +| Tavily 429 / 5xx | Exponential-backoff retry, then a clear `{success:false, error}` (tool layer must not crash the loop). | +| Timeout / connection error | Mapped to clear errors; tool returns `success:false`. | +| SSRF (private/loopback URLs) | Dropped via `_validate_public_url`. | +| Empty query | Tool returns `success:false` with message (matches `tool_exa_search`). | + +## 7. Testing + +- **`tool_tavily_search`**: `services.json` `test` block (per `docs/README-node-testing.md`), + `requires: ["ROCKETRIDE_TAVILY_KEY"]` for full runs + `ROCKETRIDE_MOCK` mock path + (mock in `nodes/test/mocks/`). Cases: valid query → results notEmpty; empty query → + `success:false`. Pure helpers (retry, URL validation) unit-tested. +- **`llm_nebius`**: `services.json` `test` block, `requires: ["ROCKETRIDE_NEBIUS_KEY"]`, + mock path. Case: simple prompt → answer notEmpty (mirrors `llm_gmi_cloud` test). +- **Contract tests** (`builder nodes:test`) validate both nodes' `services.json` + + module import. +- **Integration**: a smoke test of the template pipeline (mocked) confirming an + end-to-end question → answer with ≥1 Tavily call. ## 8. Forward compatibility ("leave room") -Per manager guidance — design so future facets attach without a rewrite: -- Broad provider name `nebius_search`; profile-based config (same extensible pattern as - `llm_perplexity`). -- Loop, Tavily client, and Nebius client are separate units in `nebius_search.py`, so a - future "pure Nebius chat" or "embeddings" facet can reuse the client wiring. -- Optionally (not now / YAGNI): expose the loop as a `@tool_function` and add - `"tool"` to `classType` so a parent agent can call Nebius Agentic Search as a tool — - mirrors `agent_deepagent/deepagent_agent/IInstance.py:53-90`. - -## 9. Testing - -- `services.json` `test` block (per `docs/README-node-testing.md`), gated with - `requires: ["ROCKETRIDE_NEBIUS_KEY", "ROCKETRIDE_TAVILY_KEY"]` for full runs, plus a - mock path via `ROCKETRIDE_MOCK` (mocks in `nodes/test/mocks/`) so CI runs without keys. -- Cases: - 1. Simple factual question → `answers` notEmpty + `documents` notEmpty (≥1 source). - 2. Multi-hop question (forces ≥2 searches under mock) → answer references both sources. - 3. Empty input → error / graceful handling. -- Contract test (`builder nodes:test`) validates `services.json` structure + module import. -- Pure helpers (query extraction, source dedup, URL validation) unit-tested directly. - -## 10. Dependencies - -`openai`, `requests` (both already used elsewhere in the repo). Synchronous; no -homomorphic crypto, no Python-3.11 constraint, no async↔sync bridge. - -## 11. Open items (confirm before/within implementation) - -1. Default Token Factory model slug (verify against Nebius model list). -2. Whether `documents` lane sources should also include Tavily's own `answer` field. -3. Phasing: this spec targets the production agentic node directly (per decision to - build Shape 2 / option 2). GTM = production node; June 18 = polish. +- `llm_nebius`: profiles let new Token Factory models be added without code changes. +- `tool_tavily_search`: Tavily also offers extract / crawl / research endpoints — each + can be added later as an additional `@tool_function` on the same node, no rewrite. +- The template composes standard nodes, so swapping the driver (deepagent ↔ Wave) or the + LLM model is pure configuration. + +## 9. Dependencies + +`requests` (Tavily tool), `langchain-openai` (Nebius LLM) — both already used in the +repo. Synchronous; no homomorphic crypto, no Python-3.11 constraint, no async bridge. + +## 10. Open items + +1. Confirm Token Factory model slug for the default (`meta-llama/Llama-3.3-70B-Instruct`?) + and that the agent loop's JSON-envelope protocol works reliably against it. +2. Build branded `llm_nebius`, or ship with existing `llm_openai_api`? (Spec assumes + `llm_nebius` for GTM branding.) +3. Default driver in the template: `agent_deepagent` (assumed) vs `agent_rocketride` Wave. From a577b79665dd5ebeb5f0bd9ffafd4be92ba544c4 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 12:42:27 -0700 Subject: [PATCH 03/16] docs(nebius_search): add step-by-step implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-01-nebius-agentic-search.md | 850 ++++++++++++++++++ 1 file changed, 850 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-nebius-agentic-search.md diff --git a/docs/superpowers/plans/2026-06-01-nebius-agentic-search.md b/docs/superpowers/plans/2026-06-01-nebius-agentic-search.md new file mode 100644 index 000000000..3bfc2ca44 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-nebius-agentic-search.md @@ -0,0 +1,850 @@ +# Nebius Agentic Search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "Nebius Agentic Search" capability — a Nebius-hosted LLM that reasons and calls Tavily web search in a loop — by composing the engine's existing agent pattern with two new provider/tool nodes plus a pipeline template. + +**Architecture:** Reuse the existing `agent_deepagent` loop. Add a new `tool_tavily_search` node (Tavily web search exposed as an agent tool, cloned from `tool_exa_search`) on the tool channel, and a new `llm_nebius` node (Nebius Token Factory LLM, cloned from `llm_gmi_cloud` with a fixed base URL) on the llm channel. Ship a `.pipe` template wiring them together. + +**Tech Stack:** Python pipeline nodes; `requests` (Tavily HTTP), `langchain-openai` (Nebius, OpenAI-compatible). Synchronous. Tests via the RocketRide node test framework (`builder nodes:test` contract tests + `services.json` `test` blocks + `ROCKETRIDE_MOCK`). + +**Conventions:** +- Code and comments in English. +- Every new `.py`/`.json` file begins with the standard MIT license header used by sibling nodes — copy it verbatim from `nodes/src/nodes/tool_exa_search/__init__.py` (lines 1–24). +- Branch: `feat/nebius-agentic-search-node` (already created off `develop`). + +**Spec:** `docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md` + +**Open items to confirm during implementation:** +1. Exact Token Factory model slug for the default (plan assumes `meta-llama/Llama-3.3-70B-Instruct`) and that the deepagent JSON-envelope loop works against it. +2. Tavily API key env var name — plan uses `TAVILY_API_KEY` (mirrors `tool_exa_search` using `EXA_API_KEY`). + +--- + +## Task 1: Scaffold `tool_tavily_search` node (config + lifecycle) + +Creates the node skeleton so the engine discovers it. Tool logic is added in Task 2. + +**Files:** +- Create: `nodes/src/nodes/tool_tavily_search/__init__.py` +- Create: `nodes/src/nodes/tool_tavily_search/IGlobal.py` +- Create: `nodes/src/nodes/tool_tavily_search/IInstance.py` (stub tool in Task 2) +- Create: `nodes/src/nodes/tool_tavily_search/services.json` +- Create: `nodes/src/nodes/tool_tavily_search/requirements.txt` +- Create: `nodes/src/nodes/tool_tavily_search/tavily.svg` +- Create: `nodes/src/nodes/tool_tavily_search/README.md` + +- [ ] **Step 1: Create `__init__.py`** + +```python +# + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] +``` + +- [ ] **Step 2: Create `requirements.txt`** + +``` +requests +``` + +- [ ] **Step 3: Create `IGlobal.py`** (clone of `tool_exa_search/IGlobal.py`, Exa→Tavily, env `TAVILY_API_KEY`) + +```python +# + +""" +Tavily Search tool node - global (shared) state. + +Reads the Tavily API key and search configuration from the node config. +Tool logic lives on IInstance via @tool_function. +""" + +from __future__ import annotations + +import os + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, error, warning + + +class IGlobal(IGlobalBase): + """Global state for tool_tavily_search.""" + + apikey: str = '' + max_results: int = 5 + search_depth: str = 'advanced' + topic: str = 'general' + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() + + if not apikey: + error('tool_tavily_search: apikey is required — set it in node config or TAVILY_API_KEY env var') + raise ValueError('tool_tavily_search: apikey is required') + + self.apikey = apikey + raw_max = cfg.get('maxResults', 5) + if raw_max is None: + raw_max = 5 + self.max_results = max(1, min(20, int(raw_max))) + self.search_depth = str(cfg.get('searchDepth') or 'advanced').strip() + self.topic = str(cfg.get('topic') or 'general').strip() + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() + if not apikey: + warning('apikey is required') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + self.apikey = '' +``` + +- [ ] **Step 4: Create stub `IInstance.py`** (real tool added in Task 2; stub keeps the module importable) + +```python +# + +"""Tavily Search tool node instance. Exposes tavily_search as a @tool_function.""" + +from __future__ import annotations + +from rocketlib import IInstanceBase + +from .IGlobal import IGlobal + + +class IInstance(IInstanceBase): + """Node instance exposing Tavily web search as an agent tool.""" + + IGlobal: IGlobal +``` + +- [ ] **Step 5: Create `services.json`** (clone of `tool_exa_search/services.json`, Tavily fields) + +```json +{ + "title": "Tavily Search", + "protocol": "tool_tavily_search://", + "classType": ["tool"], + "capabilities": ["invoke", "experimental"], + "register": "filter", + "node": "python", + "path": "nodes.tool_tavily_search", + "prefix": "tavily", + "icon": "tavily.svg", + "description": ["Exposes Tavily real-time web search as an agent tool.", "Performs live web searches via the Tavily API and returns structured results with titles, URLs, content snippets, and relevance scores."], + "tile": [], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "Tavily Search", + "apikey": "", + "maxResults": 5, + "searchDepth": "advanced", + "topic": "general" + } + } + }, + "fields": { + "tool_tavily_search.apikey": { + "type": "string", + "title": "API Key", + "description": "Tavily API key (from https://tavily.com)", + "default": "", + "secure": true, + "ui": { "ui:widget": "ApiKeyWidget" } + }, + "tool_tavily_search.maxResults": { + "type": "integer", + "title": "Max Results", + "description": "Maximum number of search results to return (1-20)", + "default": 5, + "minimum": 1, + "maximum": 20 + }, + "tool_tavily_search.searchDepth": { + "type": "string", + "title": "Search Depth", + "description": "Tavily search depth", + "default": "advanced", + "enum": [["basic", "Basic"], ["advanced", "Advanced"]] + }, + "tool_tavily_search.topic": { + "type": "string", + "title": "Topic", + "description": "Search topic category", + "default": "general", + "enum": [["general", "General"], ["news", "News"], ["finance", "Finance"]] + } + }, + "test": { + "profiles": ["default"], + "outputs": [], + "cases": [ + { "name": "Config validation with placeholder key", "text": "test query" } + ] + }, + "shape": [ + { + "section": "Pipe", + "title": "Tavily Search", + "properties": ["type", "tool_tavily_search.apikey", "tool_tavily_search.maxResults", "tool_tavily_search.searchDepth", "tool_tavily_search.topic"] + } + ] +} +``` + +- [ ] **Step 6: Create `tavily.svg`** — author a simple single-color placeholder icon (monochrome `#000`; the build auto-tints it). Minimal valid SVG: + +```xml + +``` + +- [ ] **Step 7: Create `README.md`** — short usage doc modeled on `nodes/src/nodes/tool_exa_search`'s sibling docs: what the node does, the `TAVILY_API_KEY` env var, config fields, and that it is consumed by agents via the tool invoke channel. + +- [ ] **Step 8: Run contract test to verify the node is valid and imports** + +Run: `pytest nodes/test/test_contracts.py -k tavily -v` +Expected: PASS (services.json structure valid, `nodes.tool_tavily_search` imports). + +- [ ] **Step 9: Commit** + +```bash +git add nodes/src/nodes/tool_tavily_search +git commit -m "feat(tool_tavily_search): scaffold Tavily web-search tool node" +``` + +--- + +## Task 2: Implement the `tavily_search` tool (HTTP + retry + SSRF) + +**Files:** +- Modify: `nodes/src/nodes/tool_tavily_search/IInstance.py` +- Create: `nodes/test/test_tool_tavily_search.py` + +- [ ] **Step 1: Write failing unit tests for the pure helpers** + +Create `nodes/test/test_tool_tavily_search.py`: + +```python +# + +"""Unit tests for tool_tavily_search pure helpers (no network).""" + +import importlib + +mod = importlib.import_module('nodes.tool_tavily_search.IInstance') + + +def test_shape_results_maps_tavily_fields(): + body = { + 'results': [ + {'title': 'T', 'url': 'https://example.com', 'content': 'snippet', 'score': 0.9} + ] + } + shaped = mod._shape_results('q', body) + assert shaped['success'] is True + assert shaped['query'] == 'q' + assert shaped['num_results'] == 1 + assert shaped['results'][0]['url'] == 'https://example.com' + assert shaped['results'][0]['score'] == 0.9 + + +def test_validate_public_url_rejects_loopback(): + import pytest + with pytest.raises(ValueError): + mod._validate_public_url('http://127.0.0.1/secret') + + +def test_validate_public_url_allows_public_https(): + assert mod._validate_public_url('https://example.com/page') == 'https://example.com/page' +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest nodes/test/test_tool_tavily_search.py -v` +Expected: FAIL (`_shape_results` / `_validate_public_url` not defined). + +- [ ] **Step 3: Implement the full `IInstance.py`** (tool + helpers; HTTP/retry cloned from `tool_exa_search/IInstance.py:210-257`, SSRF from `search_exa/exa_search.py:146-168`) + +```python +# + +""" +Tavily Search tool node instance. + +Exposes ``tavily_search`` as a @tool_function for real-time web search via the Tavily API. +""" + +from __future__ import annotations + +import ipaddress +import socket +import time +from typing import Any, Dict +from urllib.parse import urlparse + +import requests + +from rocketlib import IInstanceBase, tool_function, debug + +from ai.common.utils import normalize_tool_input + +from .IGlobal import IGlobal + +TAVILY_SEARCH_URL = 'https://api.tavily.com/search' +VALID_SEARCH_DEPTHS = {'basic', 'advanced'} +VALID_TOPICS = {'general', 'news', 'finance'} + + +class IInstance(IInstanceBase): + """Node instance exposing Tavily web search as an agent tool.""" + + IGlobal: IGlobal + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['query'], + 'properties': { + 'query': {'type': 'string', 'description': 'The search query — a natural language question or keyword phrase.'}, + 'max_results': {'type': 'integer', 'description': 'Number of results to return (1-20). Defaults to the node config value.'}, + 'search_depth': {'type': 'string', 'enum': sorted(VALID_SEARCH_DEPTHS), 'description': '"basic" (fast) or "advanced" (deeper). Defaults to node config.'}, + 'topic': {'type': 'string', 'enum': sorted(VALID_TOPICS), 'description': 'Search category: "general", "news", or "finance".'}, + 'time_range': {'type': 'string', 'enum': ['day', 'week', 'month', 'year'], 'description': 'Restrict results to a recent time window.'}, + 'include_domains': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Only return results from these domains.'}, + 'exclude_domains': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Exclude results from these domains.'}, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'query': {'type': 'string'}, + 'num_results': {'type': 'integer'}, + 'results': {'type': 'array', 'items': {'type': 'object'}}, + 'error': {'type': 'string'}, + }, + }, + description='Search the web in real time using Tavily. Provide a natural language query to find relevant, current web pages. Returns structured results with title, URL, content snippet, and relevance score.', + ) + def tavily_search(self, args): + """Search the web using the Tavily API.""" + args = normalize_tool_input(args, tool_name='tavily_search') + + query = (args.get('query') or '').strip() + if not query: + return {'success': False, 'query': '', 'num_results': 0, 'results': [], 'error': 'query is required and must be a non-empty string'} + + cfg = self.IGlobal + + max_results = args.get('max_results', cfg.max_results) + if isinstance(max_results, bool) or not isinstance(max_results, int): + max_results = cfg.max_results + search_depth = args.get('search_depth', cfg.search_depth) + if search_depth not in VALID_SEARCH_DEPTHS: + search_depth = cfg.search_depth + topic = args.get('topic', cfg.topic) + if topic not in VALID_TOPICS: + topic = cfg.topic + + payload: Dict[str, Any] = { + 'query': query, + 'max_results': max(1, min(20, max_results)), + 'search_depth': search_depth, + 'topic': topic, + } + time_range = args.get('time_range') + if time_range: + payload['time_range'] = str(time_range) + include_domains = args.get('include_domains') + if include_domains and isinstance(include_domains, list): + payload['include_domains'] = include_domains + exclude_domains = args.get('exclude_domains') + if exclude_domains and isinstance(exclude_domains, list): + payload['exclude_domains'] = exclude_domains + + headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + 'authorization': f'Bearer {cfg.apikey}', + } + + try: + body = _request_with_retry(url=TAVILY_SEARCH_URL, headers=headers, payload=payload) + except RuntimeError as exc: + return {'success': False, 'query': query, 'num_results': 0, 'results': [], 'error': str(exc)} + + return _shape_results(query, body) + + +def _shape_results(query: str, body: Dict[str, Any]) -> Dict[str, Any]: + """Map a Tavily response body into the tool's output schema, dropping unsafe URLs.""" + results = [] + for item in body.get('results', []) or []: + url = item.get('url', '') + try: + url = _validate_public_url(url) if url else '' + except ValueError: + continue + results.append({ + 'title': item.get('title', ''), + 'url': url, + 'content': item.get('content', ''), + 'score': item.get('score'), + 'published_date': item.get('published_date'), + }) + return {'success': True, 'query': query, 'num_results': len(results), 'results': results} + + +def _validate_public_url(raw_url: str) -> str: + """Reject private/loopback/reserved hosts to prevent SSRF (clone of search_exa).""" + parsed = urlparse(raw_url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + raise ValueError(f'Tavily returned an invalid URL: {raw_url}') + try: + addrinfo = socket.getaddrinfo(parsed.hostname, None, type=socket.SOCK_STREAM) + except socket.gaierror as e: + raise ValueError(f'Tavily returned an unresolved URL host: {parsed.hostname}') from e + for _, _, _, _, sockaddr in addrinfo: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified: + raise ValueError(f'Tavily returned a blocked URL host: {parsed.hostname}') + return raw_url + + +def _request_with_retry(*, url: str, headers: Dict[str, str], payload: Dict[str, Any], max_retries: int = 3, base_delay: float = 2.0) -> Dict[str, Any]: + """POST to the Tavily API with exponential-backoff retry on 429/5xx (clone of tool_exa_search).""" + for attempt in range(max_retries + 1): + try: + resp = requests.post(url, headers=headers, json=payload, timeout=30) + if resp.status_code == 429 or 500 <= resp.status_code < 600: + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + debug(f'Tavily transient error ({resp.status_code}), retrying in {delay}s ({attempt + 1}/{max_retries})') + time.sleep(delay) + continue + resp.raise_for_status() + resp.raise_for_status() + return resp.json() + except requests.exceptions.Timeout: + if attempt < max_retries: + time.sleep(base_delay * (2 ** attempt)) + continue + raise RuntimeError('Tavily search: request timed out after all retries') from None + except requests.RequestException as exc: + status = getattr(getattr(exc, 'response', None), 'status_code', None) + detail = f' (HTTP {status})' if status else '' + raise RuntimeError(f'Tavily search request failed{detail}: {type(exc).__name__}') from None + raise RuntimeError('Tavily search: max retries exceeded') +``` + +- [ ] **Step 4: Run the unit tests to verify they pass** + +Run: `pytest nodes/test/test_tool_tavily_search.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Run the contract test again** + +Run: `pytest nodes/test/test_contracts.py -k tavily -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add nodes/src/nodes/tool_tavily_search/IInstance.py nodes/test/test_tool_tavily_search.py +git commit -m "feat(tool_tavily_search): implement Tavily search tool with retry + SSRF guard" +``` + +--- + +## Task 3: Scaffold `llm_nebius` node (Nebius Token Factory LLM) + +Clone of `llm_gmi_cloud`, simplified to a fixed Token Factory base URL. + +**Files:** +- Create: `nodes/src/nodes/llm_nebius/__init__.py` +- Create: `nodes/src/nodes/llm_nebius/IInstance.py` +- Create: `nodes/src/nodes/llm_nebius/nebius.py` +- Create: `nodes/src/nodes/llm_nebius/IGlobal.py` +- Create: `nodes/src/nodes/llm_nebius/services.json` +- Create: `nodes/src/nodes/llm_nebius/requirements.txt` +- Create: `nodes/src/nodes/llm_nebius/nebius.svg` +- Create: `nodes/src/nodes/llm_nebius/README.md` + +- [ ] **Step 1: Create `requirements.txt`** + +``` +langchain-openai +openai +``` + +- [ ] **Step 2: Create `IInstance.py`** (identical to `llm_gmi_cloud/IInstance.py`) + +```python +# + +from ai.common.llm_base import LLMBase + + +class IInstance(LLMBase): + pass +``` + +- [ ] **Step 3: Create `__init__.py`** (exports `getChat`, like `llm_gmi_cloud/__init__.py`) + +```python +# + +from .IGlobal import IGlobal +from .IInstance import IInstance + + +def getChat(): + """Get the Chat class from the module.""" + from .nebius import Chat + + return Chat + + +__all__ = ['IGlobal', 'IInstance', 'getChat'] +``` + +- [ ] **Step 4: Create `nebius.py`** (clone of `llm_gmi_cloud/gmi_cloud.py`, fixed Token Factory base URL) + +```python +# + +"""Nebius Token Factory binding for the ChatLLM (OpenAI-compatible).""" + +from typing import Any, Dict +from openai import AuthenticationError, APIError, RateLimitError, APIConnectionError +from ai.common.chat import ChatBase +from ai.common.config import Config +from langchain_openai import ChatOpenAI + +NEBIUS_BASE_URL = 'https://api.tokenfactory.nebius.com/v1/' + + +class Chat(ChatBase): + """Creates a Nebius Token Factory chat bot.""" + + _llm: ChatOpenAI + + def __init__(self, provider: str, connConfig: Dict[str, Any], bag: Dict[str, Any]): + super().__init__(provider, connConfig, bag) + + config = Config.getNodeConfig(provider, connConfig) + + # Dummy placeholder so the client initialises before a key is saved. + apikey = config.get('apikey') or 'sk-dummy' + + self._llm = ChatOpenAI( + model=self._model, + base_url=NEBIUS_BASE_URL, + api_key=apikey, + temperature=0, + max_tokens=self._modelOutputTokens, + ) + + bag['chat'] = self + + def is_retryable_error(self, error): + return isinstance(error, (RateLimitError, APIConnectionError)) + + def map_exception(self, error): + if isinstance(error, AuthenticationError): + return ValueError('Invalid Nebius API key.') + elif isinstance(error, RateLimitError): + return ValueError(f'Nebius rate limit: {error}') + elif isinstance(error, APIConnectionError): + return ValueError('Failed to connect to the Nebius Token Factory API.') + elif isinstance(error, APIError): + return ValueError(f'Nebius API error: {error}') + else: + return super().map_exception(error) +``` + +- [ ] **Step 5: Create `IGlobal.py`** (simplified clone of `llm_gmi_cloud/IGlobal.py`; base URL is fixed, so no serverbase/SSRF handling) + +```python +# + +import os +from typing import Optional +from rocketlib import IGlobalBase, warning +from ai.common.config import Config +from ai.common.chat import ChatBase + + +class IGlobal(IGlobalBase): + """Global handler for the Nebius Token Factory LLM node.""" + + _chat: Optional[ChatBase] = None + + _VALIDATION_PROMPT = 'Hi' + _BASE_URL = 'https://api.tokenfactory.nebius.com/v1/' + + def _resolve_apikey(self, config) -> str: + return str(config.get('apikey') or os.environ.get('NEBIUS_API_KEY', '')).strip() + + def validateConfig(self): + """Probe the model with a 1-token request to validate key + model at save time.""" + from depends import depends # type: ignore + + requirements = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'requirements.txt') + depends(requirements) + + try: + from openai import OpenAI, APIStatusError, OpenAIError, AuthenticationError, RateLimitError, APIConnectionError + + config = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = self._resolve_apikey(config) + model = config.get('model') + if not model or not apikey: + return + try: + client = OpenAI(api_key=apikey, base_url=self._BASE_URL) + client.chat.completions.create( + model=model, + messages=[{'role': 'user', 'content': self._VALIDATION_PROMPT}], + max_tokens=1, + ) + except RateLimitError: + return + except APIStatusError as e: + status = getattr(e, 'status_code', None) or getattr(e, 'status', None) + if status == 429: + return + warning(f'Nebius validation error {status}: {e}') + return + except (AuthenticationError, APIConnectionError, OpenAIError) as e: + warning(str(e)) + return + except Exception as e: + warning(str(e)) + + def beginGlobal(self): + """Initialize the Nebius chat client.""" + from depends import depends # type: ignore + + requirements = os.path.dirname(os.path.realpath(__file__)) + '/requirements.txt' + depends(requirements) + + from .nebius import Chat + + bag = self.IEndpoint.endpoint.bag + config = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + if not self._resolve_apikey(config): + raise ValueError('Nebius API key is required.') + self._chat = Chat(self.glb.logicalType, config, bag) + + def endGlobal(self): + self._chat = None +``` + +- [ ] **Step 6: Create `services.json`** (curated Token Factory profiles; default = Llama 3.3 70B) + +```json +{ + "title": "Nebius", + "protocol": "llm_nebius://", + "classType": ["llm"], + "capabilities": ["invoke"], + "register": "filter", + "node": "python", + "path": "nodes.llm_nebius", + "prefix": "llm", + "icon": "nebius.svg", + "documentation": "https://docs.rocketride.org", + "description": ["Connects to Nebius Token Factory's OpenAI-compatible inference API.", "Hosts open models (Llama, Qwen, DeepSeek) for reasoning, generation, and tool-calling. Used as an `llm` invoke connection by agents, including Nebius Agentic Search."], + "tile": ["Model: ${parameters.llm_nebius.profile}"], + "lanes": { "questions": ["answers"] }, + "preconfig": { + "default": "llama-3-3-70b", + "profiles": { + "llama-3-3-70b": { + "title": "Llama 3.3 70B Instruct", + "model": "meta-llama/Llama-3.3-70B-Instruct", + "apikey": "", + "modelTotalTokens": 131072 + }, + "qwen3-235b": { + "title": "Qwen3 235B", + "model": "Qwen/Qwen3-235B-A22B", + "apikey": "", + "modelTotalTokens": 131072 + }, + "deepseek-v3": { + "title": "DeepSeek V3", + "model": "deepseek-ai/DeepSeek-V3", + "apikey": "", + "modelTotalTokens": 131072 + }, + "custom": { + "model": "", + "apikey": "", + "modelTotalTokens": 131072 + } + } + }, + "fields": { + "model": { + "type": "string", + "title": "Model", + "description": "Nebius Token Factory model id (e.g. meta-llama/Llama-3.3-70B-Instruct). Full list: https://tokenfactory.nebius.com/models" + }, + "modelTotalTokens": { "type": "number", "title": "Tokens", "description": "Total Tokens" }, + "llm_nebius.llama-3-3-70b": { "object": "llama-3-3-70b", "properties": ["llm.cloud.apikey"] }, + "llm_nebius.qwen3-235b": { "object": "qwen3-235b", "properties": ["llm.cloud.apikey"] }, + "llm_nebius.deepseek-v3": { "object": "deepseek-v3", "properties": ["llm.cloud.apikey"] }, + "llm_nebius.custom": { "object": "custom", "properties": ["model", "modelTotalTokens", "llm.cloud.apikey"] }, + "llm_nebius.profile": { + "title": "Model", + "description": "Nebius Token Factory model", + "type": "string", + "default": "llama-3-3-70b", + "enum": ["*>preconfig.profiles.*.title"], + "conditional": [ + { "value": "llama-3-3-70b", "properties": ["llm_nebius.llama-3-3-70b"] }, + { "value": "qwen3-235b", "properties": ["llm_nebius.qwen3-235b"] }, + { "value": "deepseek-v3", "properties": ["llm_nebius.deepseek-v3"] }, + { "value": "custom", "properties": ["llm_nebius.custom"] } + ] + } + }, + "shape": [ + { "section": "Pipe", "title": "Nebius", "properties": ["llm_nebius.profile"] } + ], + "test": { + "profiles": ["llama-3-3-70b"], + "outputs": ["answers"], + "cases": [ + { "name": "LLM returns mock response", "text": "What is 2+2?", "expect": { "answers": { "contains": "Mock LLM response" } } } + ] + } +} +``` + +- [ ] **Step 7: Create `nebius.svg`** — monochrome placeholder icon (`#000`), same approach as Step 6 of Task 1. + +```xml + +``` + +- [ ] **Step 8: Create `README.md`** — short doc modeled on `llm_gmi_cloud`'s description: Token Factory base URL, `NEBIUS_API_KEY` env var, model profiles, and that it is used as an `llm` channel for agents (Nebius Agentic Search). + +- [ ] **Step 9: Run contract test** + +Run: `pytest nodes/test/test_contracts.py -k nebius -v` +Expected: PASS (services.json valid, `nodes.llm_nebius` imports, `getChat` present). + +- [ ] **Step 10: Commit** + +```bash +git add nodes/src/nodes/llm_nebius +git commit -m "feat(llm_nebius): add Nebius Token Factory LLM provider node" +``` + +--- + +## Task 4: Verify `llm_nebius` integration test (mock-backed) + +The `services.json` `test` block added in Task 3 uses the existing `langchain_openai` mock (`nodes/test/mocks/langchain_openai`) which returns "Mock LLM response" — same path as `llm_gmi_cloud`. + +- [ ] **Step 1: Run the full node test for `llm_nebius` under mock** + +Run: `builder nodes:test-full --pattern="llm_nebius"` +Expected: PASS — the `llama-3-3-70b` profile returns an answer containing "Mock LLM response". + +- [ ] **Step 2: If the mock does not auto-apply** (the node uses `ChatOpenAI` exactly like `llm_gmi_cloud`, so it should), confirm no new mock is needed by diffing the import surface against `llm_gmi_cloud`. No code change expected. + +- [ ] **Step 3: Commit (only if any adjustment was required)** + +```bash +git add -A && git commit -m "test(llm_nebius): confirm mock-backed integration test passes" +``` + +--- + +## Task 5: Pipeline template "Nebius Agentic Search" + +Wire the two new nodes into the existing `agent_deepagent` and ship a `.pipe` example. + +**Files:** +- Create: `examples/nebius-agentic-search.pipe` +- (Optional) Modify: `packages/shared-ui/src/components/canvas/templates/templates.json` + +**Reference:** `pipelines/git_agent_example.pipe` — the same chat→agent←llm+tool wiring (it uses `agent_langchain` + `llm_gemini` + `tool_git`). Mirror its `components[]` + `input[]` lane/channel structure. + +- [ ] **Step 1: Author `examples/nebius-agentic-search.pipe`** with four components: + - `chat` trigger (Source) — copy the `chat_1` component from `git_agent_example.pipe`. + - `agent_deepagent` — name "Nebius Agentic Search"; `config.instructions`: "You are an agentic web-research assistant. Use the tavily_search tool to find current information, refine queries when results are weak, cite sources, and answer concisely."; receives the chat trigger on the `questions` lane. + - `llm_nebius` — connected to the agent's `llm` invoke channel (default profile `llama-3-3-70b`). + - `tool_tavily_search` — connected to the agent's `tool` invoke channel. + + Match `git_agent_example.pipe`'s JSON shape exactly (component `id`/`provider`/`config`/`ui.position`, and `input[]` entries declaring the lane/channel connections). Easiest reliable path: open the canvas, drag these four nodes, wire chat→questions→agent, llm_nebius→agent (llm channel), tool_tavily_search→agent (tool channel), then **Export** the pipeline and save the exported JSON to `examples/nebius-agentic-search.pipe`. + +- [ ] **Step 2: Validate the template imports** + +Import `examples/nebius-agentic-search.pipe` into the canvas (or run any existing pipeline-import validation in `packages/server/test/pipelines`). Expected: loads with all four nodes wired, no validation errors. + +- [ ] **Step 3: (Optional) Register the template** in `packages/shared-ui/src/components/canvas/templates/templates.json` so it appears as a one-click "Nebius Agentic Search" card. Follow the existing entries' shape in that file. + +- [ ] **Step 4: Commit** + +```bash +git add examples/nebius-agentic-search.pipe packages/shared-ui/src/components/canvas/templates/templates.json +git commit -m "feat(examples): add Nebius Agentic Search pipeline template" +``` + +--- + +## Task 6: Docs + final verification + +**Files:** +- Modify: `docs/README-nodes.md` + +- [ ] **Step 1: Add the two nodes to `docs/README-nodes.md`** — add `tool_tavily_search` near the AI/search section and `llm_nebius` to the "LLM Providers" table, each with a one-line description. + +- [ ] **Step 2: Run the full contract test suite** + +Run: `builder nodes:test` +Expected: PASS for all nodes (including the two new ones). + +- [ ] **Step 3: Run the focused integration tests** + +Run: `builder nodes:test-full --pattern="tavily" && builder nodes:test-full --pattern="llm_nebius"` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add docs/README-nodes.md +git commit -m "docs(nodes): document tool_tavily_search and llm_nebius" +``` + +--- + +## Verification checklist (whole feature) + +- [ ] `pytest nodes/test/test_contracts.py -k "tavily or nebius" -v` passes. +- [ ] `pytest nodes/test/test_tool_tavily_search.py -v` passes. +- [ ] `builder nodes:test-full --pattern="llm_nebius"` returns "Mock LLM response". +- [ ] The `.pipe` template imports cleanly with all four nodes wired. +- [ ] With real `NEBIUS_API_KEY` + `TAVILY_API_KEY`, a question to the template produces a cited answer and at least one Tavily call (manual smoke test). From 4ae8c0ebedeb33783f1582f14490a4e7ed2a9f9d Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 12:49:54 -0700 Subject: [PATCH 04/16] feat(tool_tavily_search): scaffold Tavily web-search tool node Co-Authored-By: Claude Sonnet 4.6 --- nodes/src/nodes/tool_tavily_search/IGlobal.py | 81 ++++++++++ .../src/nodes/tool_tavily_search/IInstance.py | 38 +++++ nodes/src/nodes/tool_tavily_search/README.md | 26 ++++ .../src/nodes/tool_tavily_search/__init__.py | 29 ++++ .../nodes/tool_tavily_search/requirements.txt | 1 + .../nodes/tool_tavily_search/services.json | 144 ++++++++++++++++++ nodes/src/nodes/tool_tavily_search/tavily.svg | 1 + 7 files changed, 320 insertions(+) create mode 100644 nodes/src/nodes/tool_tavily_search/IGlobal.py create mode 100644 nodes/src/nodes/tool_tavily_search/IInstance.py create mode 100644 nodes/src/nodes/tool_tavily_search/README.md create mode 100644 nodes/src/nodes/tool_tavily_search/__init__.py create mode 100644 nodes/src/nodes/tool_tavily_search/requirements.txt create mode 100644 nodes/src/nodes/tool_tavily_search/services.json create mode 100644 nodes/src/nodes/tool_tavily_search/tavily.svg diff --git a/nodes/src/nodes/tool_tavily_search/IGlobal.py b/nodes/src/nodes/tool_tavily_search/IGlobal.py new file mode 100644 index 000000000..e634e856c --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/IGlobal.py @@ -0,0 +1,81 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Tavily Search tool node - global (shared) state. + +Reads the Tavily API key and search configuration from the node config. +Tool logic lives on IInstance via @tool_function. +""" + +from __future__ import annotations + +import os + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, error, warning + + +class IGlobal(IGlobalBase): + """Global state for tool_tavily_search.""" + + apikey: str = '' + max_results: int = 5 + search_depth: str = 'advanced' + topic: str = 'general' + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() + + if not apikey: + error('tool_tavily_search: apikey is required — set it in node config or TAVILY_API_KEY env var') + raise ValueError('tool_tavily_search: apikey is required') + + self.apikey = apikey + raw_max = cfg.get('maxResults', 5) + if raw_max is None: + raw_max = 5 + self.max_results = max(1, min(20, int(raw_max))) + search_depth = str(cfg.get('searchDepth') or 'advanced').strip() + self.search_depth = search_depth if search_depth in ('basic', 'advanced') else 'advanced' + topic = str(cfg.get('topic') or 'general').strip() + self.topic = topic if topic in ('general', 'news', 'finance') else 'general' + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() + if not apikey: + warning('apikey is required') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + self.apikey = '' diff --git a/nodes/src/nodes/tool_tavily_search/IInstance.py b/nodes/src/nodes/tool_tavily_search/IInstance.py new file mode 100644 index 000000000..5811eafdf --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/IInstance.py @@ -0,0 +1,38 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Tavily Search tool node instance. Exposes tavily_search as a @tool_function.""" + +from __future__ import annotations + +from rocketlib import IInstanceBase + +from .IGlobal import IGlobal + + +class IInstance(IInstanceBase): + """Node instance exposing Tavily web search as an agent tool.""" + + IGlobal: IGlobal diff --git a/nodes/src/nodes/tool_tavily_search/README.md b/nodes/src/nodes/tool_tavily_search/README.md new file mode 100644 index 000000000..94ee20067 --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/README.md @@ -0,0 +1,26 @@ +# tool_tavily_search + +Exposes [Tavily](https://tavily.com) real-time web search as an agent tool node. + +## What it does + +Agents invoke this node via the tool invoke channel. The node performs a live web search using the Tavily API and returns structured results containing titles, URLs, content snippets, and relevance scores. + +Because `lanes` is empty (`{}`), this node has no pipeline input/output lanes — it is consumed exclusively by agent runtimes through the `invoke` capability. + +## Setup + +Set your Tavily API key via the node config field **API Key** or the environment variable: + +``` +TAVILY_API_KEY=tvly-... +``` + +## Config fields + +| Field | Default | Description | +| ------------ | ---------- | ------------------------------------------------ | +| API Key | *(empty)* | Tavily API key (from https://tavily.com). Encrypted at rest. | +| Max Results | `5` | Maximum number of results returned (1–20). | +| Search Depth | `advanced` | `basic` or `advanced` — controls result quality. | +| Topic | `general` | `general`, `news`, or `finance`. | diff --git a/nodes/src/nodes/tool_tavily_search/__init__.py b/nodes/src/nodes/tool_tavily_search/__init__.py new file mode 100644 index 000000000..37de11f28 --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/__init__.py @@ -0,0 +1,29 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_tavily_search/requirements.txt b/nodes/src/nodes/tool_tavily_search/requirements.txt new file mode 100644 index 000000000..f2293605c --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/nodes/src/nodes/tool_tavily_search/services.json b/nodes/src/nodes/tool_tavily_search/services.json new file mode 100644 index 000000000..ed0a83125 --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/services.json @@ -0,0 +1,144 @@ +{ + // + // Required: + // The displayable name of this node + // + "title": "Tavily Search", + // + // Required: + // The protocol is the endpoint protocol + // + "protocol": "tool_tavily_search://", + // + // Required: + // Class type of the node - what it does + // + "classType": ["tool"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying engine. + // "experimental" marks this node as not yet production-ready. + // + "capabilities": ["invoke", "experimental"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified. + // + "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate. + // + "node": "python", + // + // Optional: + // The path is the executable/script code. + // + "path": "nodes.tool_tavily_search", + // + // Required: + // The prefix map when converting URLs <=> paths. + // + "prefix": "tavily", + // + // Optional: + // The icon to display in the UI for this node. + // + "icon": "tavily.svg", + // + // Optional: + // Description of this node shown in the pipeline builder. + // + "description": ["Exposes Tavily real-time web search as an agent tool.", "Performs live web searches via the Tavily API and returns structured results with titles, URLs, content snippets, and relevance scores."], + "tile": [], + "lanes": {}, + // + // Optional: + // Profile section - configuration options used by the driver itself. + // The default profile is applied when the node is first added to a pipeline. + // + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "Tavily Search", + "apikey": "", + "maxResults": 5, + "searchDepth": "advanced", + "topic": "general" + } + } + }, + // + // Optional: + // Local field definitions. These define the config fields exposed in the UI. + // "secure: true" fields are encrypted at rest and masked in the UI. + // + "fields": { + "tool_tavily_search.apikey": { + "type": "string", + "title": "API Key", + "description": "Tavily API key (from https://tavily.com)", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + }, + "tool_tavily_search.maxResults": { + "type": "integer", + "title": "Max Results", + "description": "Maximum number of search results to return (1-20)", + "default": 5, + "minimum": 1, + "maximum": 20 + }, + "tool_tavily_search.searchDepth": { + "type": "string", + "title": "Search Depth", + "description": "Tavily search depth", + "default": "advanced", + "enum": [ + ["basic", "Basic"], + ["advanced", "Advanced"] + ] + }, + "tool_tavily_search.topic": { + "type": "string", + "title": "Topic", + "description": "Search topic category", + "default": "general", + "enum": [ + ["general", "General"], + ["news", "News"], + ["finance", "Finance"] + ] + } + }, + // + // Test configuration for automated node testing. + // No apikey is supplied — validateConfig emits a warning (not an error), + // so this case exercises config validation without live API calls. + // + "test": { + "profiles": ["default"], + "outputs": [], + "cases": [ + { + "name": "Config validation with placeholder key", + "text": "test query" + } + ] + }, + // + // Required: + // Defines the fields (shape) shown in the pipeline builder side panel. + // + "shape": [ + { + "section": "Pipe", + "title": "Tavily Search", + "properties": ["type", "tool_tavily_search.apikey", "tool_tavily_search.maxResults", "tool_tavily_search.searchDepth", "tool_tavily_search.topic"] + } + ] +} diff --git a/nodes/src/nodes/tool_tavily_search/tavily.svg b/nodes/src/nodes/tool_tavily_search/tavily.svg new file mode 100644 index 000000000..642d33ee2 --- /dev/null +++ b/nodes/src/nodes/tool_tavily_search/tavily.svg @@ -0,0 +1 @@ + From 29fbbf5007580fd2d4f63744d7b11b0e5e62c6d3 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 13:03:46 -0700 Subject: [PATCH 05/16] feat(tool_tavily_search): implement Tavily search tool with retry + SSRF guard Co-Authored-By: Claude Sonnet 4.6 --- .../src/nodes/tool_tavily_search/IInstance.py | 224 +++++++++++++++++- nodes/test/test_tool_tavily_search.py | 115 +++++++++ 2 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 nodes/test/test_tool_tavily_search.py diff --git a/nodes/src/nodes/tool_tavily_search/IInstance.py b/nodes/src/nodes/tool_tavily_search/IInstance.py index 5811eafdf..69e778529 100644 --- a/nodes/src/nodes/tool_tavily_search/IInstance.py +++ b/nodes/src/nodes/tool_tavily_search/IInstance.py @@ -23,16 +23,236 @@ # SOFTWARE. # ============================================================================= -"""Tavily Search tool node instance. Exposes tavily_search as a @tool_function.""" +""" +Tavily Search tool node instance. + +Exposes ``tavily_search`` as a @tool_function for real-time web search via the Tavily API. +""" from __future__ import annotations -from rocketlib import IInstanceBase +import ipaddress +import socket +import time +from typing import Any, Dict +from urllib.parse import urlparse + +import requests + +from rocketlib import IInstanceBase, tool_function, debug + +from ai.common.utils import normalize_tool_input from .IGlobal import IGlobal +TAVILY_SEARCH_URL = 'https://api.tavily.com/search' +VALID_SEARCH_DEPTHS = {'basic', 'advanced'} +VALID_TOPICS = {'general', 'news', 'finance'} +VALID_TIME_RANGES = {'day', 'week', 'month', 'year'} + class IInstance(IInstanceBase): """Node instance exposing Tavily web search as an agent tool.""" IGlobal: IGlobal + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['query'], + 'properties': { + 'query': { + 'type': 'string', + 'description': 'The search query — a natural language question or keyword phrase.', + }, + 'max_results': { + 'type': 'integer', + 'description': 'Number of results to return (1-20). Defaults to the node config value.', + }, + 'search_depth': { + 'type': 'string', + 'enum': sorted(VALID_SEARCH_DEPTHS), + 'description': '"basic" (fast) or "advanced" (deeper). Defaults to node config.', + }, + 'topic': { + 'type': 'string', + 'enum': sorted(VALID_TOPICS), + 'description': 'Search category: "general", "news", or "finance".', + }, + 'time_range': { + 'type': 'string', + 'enum': ['day', 'week', 'month', 'year'], + 'description': 'Restrict results to a recent time window.', + }, + 'include_domains': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Only return results from these domains.', + }, + 'exclude_domains': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Exclude results from these domains.', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'query': {'type': 'string'}, + 'num_results': {'type': 'integer'}, + 'results': {'type': 'array', 'items': {'type': 'object'}}, + 'error': {'type': 'string'}, + }, + }, + description='Search the web in real time using Tavily. Provide a natural language query to find relevant, current web pages. Returns structured results with title, URL, content snippet, and relevance score.', + ) + def tavily_search(self, args): + """Search the web using the Tavily API.""" + args = normalize_tool_input(args, tool_name='tavily_search') + + query = (args.get('query') or '').strip() + if not query: + return { + 'success': False, + 'query': '', + 'num_results': 0, + 'results': [], + 'error': 'query is required and must be a non-empty string', + } + + cfg = self.IGlobal + + max_results = args.get('max_results', cfg.max_results) + if isinstance(max_results, bool) or not isinstance(max_results, int): + max_results = cfg.max_results + search_depth = args.get('search_depth', cfg.search_depth) + if search_depth not in VALID_SEARCH_DEPTHS: + search_depth = cfg.search_depth + topic = args.get('topic', cfg.topic) + if topic not in VALID_TOPICS: + topic = cfg.topic + + payload: Dict[str, Any] = { + 'query': query, + 'max_results': max(1, min(20, max_results)), + 'search_depth': search_depth, + 'topic': topic, + } + time_range = args.get('time_range') + if time_range in VALID_TIME_RANGES: + payload['time_range'] = time_range + include_domains = args.get('include_domains') + if include_domains and isinstance(include_domains, list): + payload['include_domains'] = include_domains + exclude_domains = args.get('exclude_domains') + if exclude_domains and isinstance(exclude_domains, list): + payload['exclude_domains'] = exclude_domains + + headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + 'authorization': f'Bearer {cfg.apikey}', + } + + try: + body = _request_with_retry(url=TAVILY_SEARCH_URL, headers=headers, payload=payload) + except RuntimeError as exc: + return {'success': False, 'query': query, 'num_results': 0, 'results': [], 'error': str(exc)} + + return _shape_results(query, body) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _shape_results(query: str, body: Dict[str, Any]) -> Dict[str, Any]: + """Map a Tavily response body into the tool's output schema, dropping unsafe URLs.""" + results = [] + for item in body.get('results', []) or []: + url = item.get('url', '') + if not url: + continue + try: + url = _validate_public_url(url) + except ValueError: + continue + results.append( + { + 'title': item.get('title', ''), + 'url': url, + 'content': item.get('content', ''), + 'score': item.get('score'), + 'published_date': item.get('published_date'), + } + ) + return {'success': True, 'query': query, 'num_results': len(results), 'results': results} + + +def _validate_public_url(raw_url: str) -> str: + """Reject private/loopback/reserved hosts to prevent SSRF (clone of search_exa).""" + parsed = urlparse(raw_url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + raise ValueError(f'Tavily returned an invalid URL: {raw_url}') + try: + addrinfo = socket.getaddrinfo(parsed.hostname, None, type=socket.SOCK_STREAM) + except socket.gaierror as e: + raise ValueError(f'Tavily returned an unresolved URL host: {parsed.hostname}') from e + for _, _, _, _, sockaddr in addrinfo: + ip = ipaddress.ip_address(sockaddr[0]) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ): + raise ValueError(f'Tavily returned a blocked URL host: {parsed.hostname}') + return raw_url + + +def _request_with_retry( + *, url: str, headers: Dict[str, str], payload: Dict[str, Any], max_retries: int = 3, base_delay: float = 2.0 +) -> Dict[str, Any]: + """POST to the Tavily API with exponential-backoff retry on 429/5xx (clone of tool_exa_search).""" + for attempt in range(max_retries + 1): + try: + resp = requests.post(url, headers=headers, json=payload, timeout=30) + + if resp.status_code == 429: + if attempt < max_retries: + delay = base_delay * (2**attempt) + debug(f'Tavily rate limit hit (429), retrying in {delay}s (attempt {attempt + 1}/{max_retries})') + time.sleep(delay) + continue + resp.raise_for_status() + + if 500 <= resp.status_code < 600: + if attempt < max_retries: + delay = base_delay * (2**attempt) + debug( + f'Tavily server error ({resp.status_code}), retrying in {delay}s (attempt {attempt + 1}/{max_retries})' + ) + time.sleep(delay) + continue + resp.raise_for_status() + + resp.raise_for_status() + return resp.json() + + except requests.exceptions.Timeout: + if attempt < max_retries: + delay = base_delay * (2**attempt) + debug(f'Tavily request timeout, retrying in {delay}s ({attempt + 1}/{max_retries})') + time.sleep(delay) + continue + raise RuntimeError('Tavily search: request timed out after all retries') from None + except requests.RequestException as exc: + status = getattr(getattr(exc, 'response', None), 'status_code', None) + detail = f' (HTTP {status})' if status else '' + raise RuntimeError(f'Tavily search request failed{detail}: {type(exc).__name__}') from None + raise RuntimeError('Tavily search: max retries exceeded') diff --git a/nodes/test/test_tool_tavily_search.py b/nodes/test/test_tool_tavily_search.py new file mode 100644 index 000000000..4478d0b1a --- /dev/null +++ b/nodes/test/test_tool_tavily_search.py @@ -0,0 +1,115 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Unit tests for tool_tavily_search pure helpers (no network).""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +# --------------------------------------------------------------------------- +# Bootstrap: inject lightweight stubs so the module can be imported without +# the full engine runtime (pydantic, engLib, etc.). +# --------------------------------------------------------------------------- + +# Stub out rocketlib — the unit-tests only exercise pure helper functions +# (_shape_results, _validate_public_url) that live at module level and have no +# dependency on the engine runtime. +_rocketlib_stub = MagicMock() +_rocketlib_stub.IInstanceBase = object # must be a real class for inheritance +_rocketlib_stub.IGlobalBase = object +_rocketlib_stub.tool_function = lambda **kwargs: lambda f: f # pass-through decorator +_rocketlib_stub.debug = lambda *a, **kw: None +_rocketlib_stub.error = lambda *a, **kw: None +_rocketlib_stub.warning = lambda *a, **kw: None +_rocketlib_stub.OPEN_MODE = MagicMock() +sys.modules.setdefault('rocketlib', _rocketlib_stub) + +# Stub depends (used by nodes/__init__.py and ai/__init__.py) +_depends_stub = MagicMock() +_depends_stub.depends = lambda *a, **kw: None +sys.modules.setdefault('depends', _depends_stub) + +# Stub ai.common.utils — normalize_tool_input is called inside the tool method, +# not in the helper functions under test, so a trivial pass-through is enough. +_ai_common_utils_stub = MagicMock() +_ai_common_utils_stub.normalize_tool_input = lambda args, **kw: args if isinstance(args, dict) else {} +sys.modules.setdefault('ai', MagicMock()) +sys.modules.setdefault('ai.common', MagicMock()) +sys.modules.setdefault('ai.common.utils', _ai_common_utils_stub) +sys.modules.setdefault('ai.common.config', MagicMock()) + +# Stub out requests — the unit tests exercise pure helpers that do no I/O. +_requests_stub = MagicMock() +_requests_exceptions_stub = MagicMock() +_requests_exceptions_stub.Timeout = TimeoutError +_requests_exceptions_stub.RequestException = Exception +_requests_stub.exceptions = _requests_exceptions_stub +sys.modules.setdefault('requests', _requests_stub) + +# Add nodes/src to sys.path so `nodes.tool_tavily_search.IInstance` is resolvable. +_NODES_SRC = Path(__file__).resolve().parents[1] / 'src' +if str(_NODES_SRC) not in sys.path: + sys.path.insert(0, str(_NODES_SRC)) + +# --------------------------------------------------------------------------- +# Load the module under test +# --------------------------------------------------------------------------- + +import importlib + +mod = importlib.import_module('nodes.tool_tavily_search.IInstance') + + +def test_shape_results_maps_tavily_fields(): + body = {'results': [{'title': 'T', 'url': 'https://example.com', 'content': 'snippet', 'score': 0.9}]} + shaped = mod._shape_results('q', body) + assert shaped['success'] is True + assert shaped['query'] == 'q' + assert shaped['num_results'] == 1 + assert shaped['results'][0]['url'] == 'https://example.com' + assert shaped['results'][0]['score'] == 0.9 + assert shaped['results'][0]['content'] == 'snippet' + assert shaped['results'][0]['published_date'] is None + + +def test_validate_public_url_rejects_loopback(): + import pytest + + with pytest.raises(ValueError): + mod._validate_public_url('http://127.0.0.1/secret') + + +def test_validate_public_url_allows_public_https(monkeypatch): + import socket + + monkeypatch.setattr( + socket, + 'getaddrinfo', + lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 0, '', ('93.184.216.34', 0))], + ) + assert mod._validate_public_url('https://example.com/page') == 'https://example.com/page' From 46819a23db821d47a1fa8a2fd326f605a74f9308 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 13:21:40 -0700 Subject: [PATCH 06/16] feat(llm_nebius): add Nebius Token Factory LLM provider node Scaffolds llm_nebius as an OpenAI-compatible LLM provider pointing at https://api.tokenfactory.nebius.com/v1/ with profiles for Llama 3.3 70B (default), Qwen3 235B, DeepSeek V3, and a custom slot. --- nodes/src/nodes/llm_nebius/IGlobal.py | 101 ++++++++++ nodes/src/nodes/llm_nebius/IInstance.py | 28 +++ nodes/src/nodes/llm_nebius/README.md | 44 +++++ nodes/src/nodes/llm_nebius/__init__.py | 39 ++++ nodes/src/nodes/llm_nebius/nebius.py | 74 ++++++++ nodes/src/nodes/llm_nebius/nebius.svg | 1 + nodes/src/nodes/llm_nebius/requirements.txt | 2 + nodes/src/nodes/llm_nebius/services.json | 194 ++++++++++++++++++++ 8 files changed, 483 insertions(+) create mode 100644 nodes/src/nodes/llm_nebius/IGlobal.py create mode 100644 nodes/src/nodes/llm_nebius/IInstance.py create mode 100644 nodes/src/nodes/llm_nebius/README.md create mode 100644 nodes/src/nodes/llm_nebius/__init__.py create mode 100644 nodes/src/nodes/llm_nebius/nebius.py create mode 100644 nodes/src/nodes/llm_nebius/nebius.svg create mode 100644 nodes/src/nodes/llm_nebius/requirements.txt create mode 100644 nodes/src/nodes/llm_nebius/services.json diff --git a/nodes/src/nodes/llm_nebius/IGlobal.py b/nodes/src/nodes/llm_nebius/IGlobal.py new file mode 100644 index 000000000..a4a78a16e --- /dev/null +++ b/nodes/src/nodes/llm_nebius/IGlobal.py @@ -0,0 +1,101 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +import os +from typing import Optional +from rocketlib import IGlobalBase, warning +from ai.common.config import Config +from ai.common.chat import ChatBase + + +class IGlobal(IGlobalBase): + """Global handler for the Nebius Token Factory LLM node.""" + + _chat: Optional[ChatBase] = None + + _VALIDATION_PROMPT = 'Hi' + _BASE_URL = 'https://api.tokenfactory.nebius.com/v1/' + + def _resolve_apikey(self, config) -> str: + return str(config.get('apikey') or os.environ.get('NEBIUS_API_KEY', '')).strip() + + def validateConfig(self): + """Probe the model with a 1-token request to validate key + model at save time.""" + from depends import depends # type: ignore + + requirements = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'requirements.txt') + depends(requirements) + + try: + from openai import ( + OpenAI, + APIStatusError, + OpenAIError, + AuthenticationError, + RateLimitError, + APIConnectionError, + ) + + config = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = self._resolve_apikey(config) + model = config.get('model') + if not model or not apikey: + return + try: + client = OpenAI(api_key=apikey, base_url=self._BASE_URL) + client.chat.completions.create( + model=model, + messages=[{'role': 'user', 'content': self._VALIDATION_PROMPT}], + max_tokens=1, + ) + except RateLimitError: + return + except APIStatusError as e: + status = getattr(e, 'status_code', None) or getattr(e, 'status', None) + if status == 429: + return + warning(f'Nebius validation error {status}: {e}') + return + except (AuthenticationError, APIConnectionError, OpenAIError) as e: + warning(str(e)) + return + except Exception as e: + warning(str(e)) + + def beginGlobal(self): + """Initialize the Nebius chat client.""" + from depends import depends # type: ignore + + requirements = os.path.dirname(os.path.realpath(__file__)) + '/requirements.txt' + depends(requirements) + + from .nebius import Chat + + bag = self.IEndpoint.endpoint.bag + config = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + if not self._resolve_apikey(config): + raise ValueError('Nebius API key is required.') + self._chat = Chat(self.glb.logicalType, config, bag) + + def endGlobal(self): + self._chat = None diff --git a/nodes/src/nodes/llm_nebius/IInstance.py b/nodes/src/nodes/llm_nebius/IInstance.py new file mode 100644 index 000000000..77e200613 --- /dev/null +++ b/nodes/src/nodes/llm_nebius/IInstance.py @@ -0,0 +1,28 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from ai.common.llm_base import LLMBase + + +class IInstance(LLMBase): + pass diff --git a/nodes/src/nodes/llm_nebius/README.md b/nodes/src/nodes/llm_nebius/README.md new file mode 100644 index 000000000..e0c4a1fe0 --- /dev/null +++ b/nodes/src/nodes/llm_nebius/README.md @@ -0,0 +1,44 @@ +--- +title: Nebius +date: 2026-06-01 +sidebar_position: 1 +--- + + + Nebius - RocketRide Documentation + + +## What it does + +Connects Nebius Token Factory-hosted models to your pipeline via an OpenAI-compatible API. The base URL is fixed at `https://api.tokenfactory.nebius.com/v1/` — no endpoint configuration needed. Used primarily as an `llm` invoke connection by agents (including Nebius Agentic Search) and other nodes that need an LLM. Can also be used directly via lanes. + +**Lanes:** + +| Lane in | Lane out | Description | +| ----------- | --------- | ---------------------------------------------------- | +| `questions` | `answers` | Send a question directly, receive a generated answer | + +## Configuration + +| Field | Description | +| ------- | ----------------------------------------------- | +| Model | Model profile or custom model ID (see below) | +| API Key | Nebius Token Factory API key (`NEBIUS_API_KEY`) | + +The API key can be supplied via the node's **API Key** field or the `NEBIUS_API_KEY` environment variable. + +## Model profiles + +| Profile | Model ID | Context | +| -------------------------- | --------------------------------------- | ------- | +| Llama 3.3 70B _(default)_ | `meta-llama/Llama-3.3-70B-Instruct` | 131,072 | +| Qwen3 235B | `Qwen/Qwen3-235B-A22B` | 131,072 | +| DeepSeek V3 | `deepseek-ai/DeepSeek-V3` | 131,072 | +| Custom | any Token Factory model ID | 131,072 | + +**Custom** — specify any Nebius Token Factory model ID and token limit directly. + +## Upstream docs + +- [Nebius Token Factory model catalogue](https://tokenfactory.nebius.com/models) +- [Nebius AI documentation](https://docs.nebius.com) diff --git a/nodes/src/nodes/llm_nebius/__init__.py b/nodes/src/nodes/llm_nebius/__init__.py new file mode 100644 index 000000000..f56614275 --- /dev/null +++ b/nodes/src/nodes/llm_nebius/__init__.py @@ -0,0 +1,39 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from .IGlobal import IGlobal +from .IInstance import IInstance + + +def getChat(): + """Get the Chat class from the module.""" + from .nebius import Chat + + return Chat + + +__all__ = [ + 'IGlobal', + 'IInstance', + 'getChat', +] diff --git a/nodes/src/nodes/llm_nebius/nebius.py b/nodes/src/nodes/llm_nebius/nebius.py new file mode 100644 index 000000000..70d3c1b93 --- /dev/null +++ b/nodes/src/nodes/llm_nebius/nebius.py @@ -0,0 +1,74 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Nebius Token Factory binding for the ChatLLM (OpenAI-compatible).""" + +import os +from typing import Any, Dict +from openai import AuthenticationError, APIError, RateLimitError, APIConnectionError +from ai.common.chat import ChatBase +from ai.common.config import Config +from langchain_openai import ChatOpenAI + +NEBIUS_BASE_URL = 'https://api.tokenfactory.nebius.com/v1/' + + +class Chat(ChatBase): + """Creates a Nebius Token Factory chat bot.""" + + _llm: ChatOpenAI + + def __init__(self, provider: str, connConfig: Dict[str, Any], bag: Dict[str, Any]): + super().__init__(provider, connConfig, bag) + + config = Config.getNodeConfig(provider, connConfig) + + # Resolve the key from config first, then the NEBIUS_API_KEY env var. + # The 'sk-dummy' placeholder lets the client initialise before a key is + # saved (e.g. during config-only open mode). + apikey = config.get('apikey') or os.environ.get('NEBIUS_API_KEY') or 'sk-dummy' + + self._llm = ChatOpenAI( + model=self._model, + base_url=NEBIUS_BASE_URL, + api_key=apikey, + temperature=0, + max_tokens=self._modelOutputTokens, + ) + + bag['chat'] = self + + def is_retryable_error(self, error): + return isinstance(error, (RateLimitError, APIConnectionError)) + + def map_exception(self, error): + if isinstance(error, AuthenticationError): + return ValueError('Invalid Nebius API key.') + elif isinstance(error, RateLimitError): + return ValueError(f'Nebius rate limit: {error}') + elif isinstance(error, APIConnectionError): + return ValueError('Failed to connect to the Nebius Token Factory API.') + elif isinstance(error, APIError): + return ValueError(f'Nebius API error: {error}') + else: + return super().map_exception(error) diff --git a/nodes/src/nodes/llm_nebius/nebius.svg b/nodes/src/nodes/llm_nebius/nebius.svg new file mode 100644 index 000000000..461837eaf --- /dev/null +++ b/nodes/src/nodes/llm_nebius/nebius.svg @@ -0,0 +1 @@ + diff --git a/nodes/src/nodes/llm_nebius/requirements.txt b/nodes/src/nodes/llm_nebius/requirements.txt new file mode 100644 index 000000000..cd7bae8f6 --- /dev/null +++ b/nodes/src/nodes/llm_nebius/requirements.txt @@ -0,0 +1,2 @@ +langchain-openai +openai diff --git a/nodes/src/nodes/llm_nebius/services.json b/nodes/src/nodes/llm_nebius/services.json new file mode 100644 index 000000000..9601278d0 --- /dev/null +++ b/nodes/src/nodes/llm_nebius/services.json @@ -0,0 +1,194 @@ +{ + // + // Required: + // The displayable name of this node + // + "title": "Nebius", + // + // Required: + // The protocol is the endpoint protocol + // + "protocol": "llm_nebius://", + // + // Required: + // Class type of the node - what it does + // + "classType": ["llm"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying + // engine + // + "capabilities": ["invoke"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified. If the + // type is specified, a factory is registered of that given type + // + "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate - if + // not specified, the protocol will be used + // + "node": "python", + // + // Optional: + // The path is the executable/script code - it is node dependent + // and is optional for most node + // + "path": "nodes.llm_nebius", + // + // Required: + // The prefix map when added/removed when converting URLs <=> paths + // + "prefix": "llm", + // + // Optional: + // Description of this driver + // + "description": ["Connects to Nebius Token Factory's OpenAI-compatible inference API.", "Hosts open models (Llama, Qwen, DeepSeek) for reasoning, generation, and tool-calling. Used as an `llm` invoke connection by agents, including Nebius Agentic Search."], + // + // Optional: + // The icon is the icon to display in the UI for this node + // + "icon": "nebius.svg", + "documentation": "https://docs.rocketride.org", + // + // Optional: + // Rendering hints to the UI which indicate which fields of + // the configuration should be used to display information + // on the tile box + // + "tile": ["Model: ${parameters.llm_nebius.profile}"], + // + // Optional: + // As a pipe component, define what this pipe component takes + // and what it produces + // + "lanes": { + "questions": ["answers"] + }, + // + // Optional: + // Profile section are configuration options used by the driver + // itself + // + "preconfig": { + // Define the values that will be merged into any profile configuration + // specified, unless the profile is 'absolute' + "default": "llama-3-3-70b", + // Defines profiles used with the "profile": key + "profiles": { + "llama-3-3-70b": { + "title": "Llama 3.3 70B Instruct", + "model": "meta-llama/Llama-3.3-70B-Instruct", + "apikey": "", + "modelTotalTokens": 131072 + }, + "qwen3-235b": { + "title": "Qwen3 235B", + "model": "Qwen/Qwen3-235B-A22B", + "apikey": "", + "modelTotalTokens": 131072 + }, + "deepseek-v3": { + "title": "DeepSeek V3", + "model": "deepseek-ai/DeepSeek-V3", + "apikey": "", + "modelTotalTokens": 131072 + }, + "custom": { + "model": "", + "apikey": "", + "modelTotalTokens": 131072 + } + } + }, + // + // Optional: + // Local fields definitions - these define fields only for the + // current service. You may specify them here, or directly + // in the shape + // + "fields": { + "model": { + "type": "string", + "title": "Model", + "description": "Nebius Token Factory model id (e.g. meta-llama/Llama-3.3-70B-Instruct). Full list: https://tokenfactory.nebius.com/models" + }, + "modelTotalTokens": { + "type": "number", + "title": "Tokens", + "description": "Total Tokens" + }, + "llm_nebius.llama-3-3-70b": { + "object": "llama-3-3-70b", + "properties": ["llm.cloud.apikey"] + }, + "llm_nebius.qwen3-235b": { + "object": "qwen3-235b", + "properties": ["llm.cloud.apikey"] + }, + "llm_nebius.deepseek-v3": { + "object": "deepseek-v3", + "properties": ["llm.cloud.apikey"] + }, + "llm_nebius.custom": { + "object": "custom", + "properties": ["model", "modelTotalTokens", "llm.cloud.apikey"] + }, + "llm_nebius.profile": { + "title": "Model", + "description": "Nebius Token Factory model", + "type": "string", + "default": "llama-3-3-70b", + "enum": ["*>preconfig.profiles.*.title"], + "conditional": [ + { + "value": "llama-3-3-70b", + "properties": ["llm_nebius.llama-3-3-70b"] + }, + { + "value": "qwen3-235b", + "properties": ["llm_nebius.qwen3-235b"] + }, + { + "value": "deepseek-v3", + "properties": ["llm_nebius.deepseek-v3"] + }, + { + "value": "custom", + "properties": ["llm_nebius.custom"] + } + ] + } + }, + // + // Required: + // Defines the fields (shape) of the service. Either source or target + // may be specified, or both, but at least one is required + // + "shape": [ + { + "section": "Pipe", + "title": "Nebius", + "properties": ["llm_nebius.profile"] + } + ], + "test": { + "profiles": ["llama-3-3-70b"], + "outputs": ["answers"], + "cases": [ + { + "name": "LLM returns mock response", + "text": "What is 2+2?", + "expect": { + "answers": { + "contains": "Mock LLM response" + } + } + } + ] + } +} From c86711e58be1c122c971cd1f500f40f53bb6ed11 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 13:32:12 -0700 Subject: [PATCH 07/16] feat(examples): add Nebius Agentic Search pipeline template Co-Authored-By: Claude Sonnet 4.6 --- examples/nebius-agentic-search.pipe | 154 ++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 examples/nebius-agentic-search.pipe diff --git a/examples/nebius-agentic-search.pipe b/examples/nebius-agentic-search.pipe new file mode 100644 index 000000000..cbe633575 --- /dev/null +++ b/examples/nebius-agentic-search.pipe @@ -0,0 +1,154 @@ +{ + "name": "Nebius Agentic Search", + "description": "A web-research agent powered by Nebius (Llama 3.3 70B) and Tavily real-time search. Ask any question and the agent will search the web, refine its queries, and return a cited answer.", + "components": [ + { + "id": "chat_1", + "provider": "chat", + "name": "Trigger", + "config": { + "hideForm": true, + "mode": "Source", + "parameters": {}, + "type": "chat" + }, + "ui": { + "position": { + "x": 20, + "y": 200 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "agent_deepagent_1", + "provider": "agent_deepagent", + "name": "Nebius Agentic Search", + "config": { + "instructions": [ + "You are an agentic web-research assistant.", + "Use the tavily_search tool to find current information; refine your query and search again when results are weak.", + "Cite the source URLs you used and answer concisely." + ], + "parameters": {} + }, + "ui": { + "position": { + "x": 240, + "y": 200 + }, + "measured": { + "width": 150, + "height": 86 + }, + "nodeType": "default", + "formDataValid": true + }, + "input": [ + { + "lane": "questions", + "from": "chat_1" + } + ] + }, + { + "id": "llm_nebius_1", + "provider": "llm_nebius", + "name": "Nebius LLM", + "config": { + "profile": "llama-3-3-70b", + "llama-3-3-70b": { + "apikey": "${NEBIUS_API_KEY}" + }, + "parameters": {} + }, + "ui": { + "position": { + "x": 130, + "y": 380 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + }, + "control": [ + { + "classType": "llm", + "from": "agent_deepagent_1" + } + ] + }, + { + "id": "tool_tavily_search_1", + "provider": "tool_tavily_search", + "name": "Tavily Search", + "config": { + "type": "tool_tavily_search", + "apikey": "${TAVILY_API_KEY}", + "maxResults": 5, + "searchDepth": "advanced", + "topic": "general" + }, + "ui": { + "position": { + "x": 350, + "y": 380 + }, + "measured": { + "width": 150, + "height": 37 + }, + "nodeType": "default", + "formDataValid": true + }, + "control": [ + { + "classType": "tool", + "from": "agent_deepagent_1" + } + ] + }, + { + "id": "response_answers_1", + "provider": "response_answers", + "config": { + "laneName": "answers" + }, + "ui": { + "position": { + "x": 447, + "y": 222 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + }, + "input": [ + { + "lane": "answers", + "from": "agent_deepagent_1" + } + ] + } + ], + "source": "chat_1", + "project_id": "b7d3e1a2-9f4c-4b8e-a5d6-2c8f1e0b3a7d", + "viewport": { + "x": 15.5, + "y": -120.9, + "zoom": 1.04 + }, + "version": 1, + "docRevision": 1 +} From 1734e6b9a6dc00445e42e8daa76886527b8a766d Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 13:34:10 -0700 Subject: [PATCH 08/16] docs(nodes): document tool_tavily_search and llm_nebius --- docs/README-nodes.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/README-nodes.md b/docs/README-nodes.md index 60fb6d5ae..9d576c3bc 100644 --- a/docs/README-nodes.md +++ b/docs/README-nodes.md @@ -22,6 +22,7 @@ For information on testing nodes, see [README-node-testing.md](README-node-testi | `llm_xai` | xAI (Grok) | | | `llm_vertex` | Google Vertex AI | | | `llm_ibm_watson` | IBM Watson | | +| `llm_nebius` | Nebius Token Factory (OpenAI-compatible) | [README](../nodes/src/nodes/llm_nebius/README.md) | | `llm_vision_mistral` | Mistral Vision (multimodal, image-to-text) | [README](../nodes/src/nodes/llm_vision_mistral/README.md) | ## Vector Databases @@ -99,6 +100,16 @@ The `core` module provides built-in connectors for OneDrive, SharePoint, Google | `text_output` | Text output | | `local_text_output` | Local text file output | +## Agent Tools + +Tool nodes (`classType: ["tool"]`) expose capabilities to agents via the control-plane invoke channel rather than data lanes. + +| Node | Description | Documentation | +| -------------------- | ------------------------------------ | ------------------------------------------------------------ | +| `tool_tavily_search` | Tavily real-time web search for agents | [README](../nodes/src/nodes/tool_tavily_search/README.md) | + +The `tool_tavily_search` node pairs with `llm_nebius` and `agent_deepagent` to build Nebius Agentic Search — see `examples/nebius-agentic-search.pipe`. + ## Internal | Node | Description | From 22e586f3e7f9f116ea3b53c8a110c0500110e9cb Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 13:50:17 -0700 Subject: [PATCH 09/16] fix(tool_tavily_search): isolate test sys.modules stubs to prevent cross-test contamination The unit-test bootstrap stubbed rocketlib/ai.common/requests via sys.modules.setdefault but never removed them. Under the full `builder nodes:test-full` run (shared pytest session, xdist workers) the leaked MagicMock stubs overrode the real modules for sibling node tests, causing 27 spurious failures in tool_git and tool_filesystem (real on this branch, absent on develop). Now stubs are injected only when missing and dropped immediately after importing the module under test, so nothing leaks into the shared session. Co-Authored-By: Claude Opus 4.8 --- nodes/test/test_tool_tavily_search.py | 92 +++++++++++++++------------ 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/nodes/test/test_tool_tavily_search.py b/nodes/test/test_tool_tavily_search.py index 4478d0b1a..7f194d48b 100644 --- a/nodes/test/test_tool_tavily_search.py +++ b/nodes/test/test_tool_tavily_search.py @@ -32,58 +32,70 @@ from unittest.mock import MagicMock # --------------------------------------------------------------------------- -# Bootstrap: inject lightweight stubs so the module can be imported without -# the full engine runtime (pydantic, engLib, etc.). +# Bootstrap: when run under a bare interpreter that lacks the engine runtime +# (rocketlib, ai.common, requests), inject lightweight stubs ONLY for modules +# that are not already present, import the module under test, then REMOVE the +# stubs we added. Restoring is essential: under the full `builder nodes:test-full` +# run these modules are real and shared across the whole pytest session, so a +# leaked MagicMock stub would break unrelated nodes' tests (e.g. tool_git, +# tool_filesystem, which rely on the real rocketlib schema helpers). The pure +# helpers under test (_shape_results, _validate_public_url) hold no runtime +# dependency on the stubbed modules, so dropping the stubs after import is safe. # --------------------------------------------------------------------------- -# Stub out rocketlib — the unit-tests only exercise pure helper functions -# (_shape_results, _validate_public_url) that live at module level and have no -# dependency on the engine runtime. -_rocketlib_stub = MagicMock() -_rocketlib_stub.IInstanceBase = object # must be a real class for inheritance -_rocketlib_stub.IGlobalBase = object -_rocketlib_stub.tool_function = lambda **kwargs: lambda f: f # pass-through decorator -_rocketlib_stub.debug = lambda *a, **kw: None -_rocketlib_stub.error = lambda *a, **kw: None -_rocketlib_stub.warning = lambda *a, **kw: None -_rocketlib_stub.OPEN_MODE = MagicMock() -sys.modules.setdefault('rocketlib', _rocketlib_stub) - -# Stub depends (used by nodes/__init__.py and ai/__init__.py) -_depends_stub = MagicMock() -_depends_stub.depends = lambda *a, **kw: None -sys.modules.setdefault('depends', _depends_stub) - -# Stub ai.common.utils — normalize_tool_input is called inside the tool method, -# not in the helper functions under test, so a trivial pass-through is enough. -_ai_common_utils_stub = MagicMock() -_ai_common_utils_stub.normalize_tool_input = lambda args, **kw: args if isinstance(args, dict) else {} -sys.modules.setdefault('ai', MagicMock()) -sys.modules.setdefault('ai.common', MagicMock()) -sys.modules.setdefault('ai.common.utils', _ai_common_utils_stub) -sys.modules.setdefault('ai.common.config', MagicMock()) - -# Stub out requests — the unit tests exercise pure helpers that do no I/O. -_requests_stub = MagicMock() -_requests_exceptions_stub = MagicMock() -_requests_exceptions_stub.Timeout = TimeoutError -_requests_exceptions_stub.RequestException = Exception -_requests_stub.exceptions = _requests_exceptions_stub -sys.modules.setdefault('requests', _requests_stub) +import importlib # Add nodes/src to sys.path so `nodes.tool_tavily_search.IInstance` is resolvable. _NODES_SRC = Path(__file__).resolve().parents[1] / 'src' if str(_NODES_SRC) not in sys.path: sys.path.insert(0, str(_NODES_SRC)) -# --------------------------------------------------------------------------- -# Load the module under test -# --------------------------------------------------------------------------- -import importlib +def _build_import_stubs(): + """Return {module_name: stub} for the deps needed only to import the module.""" + rocketlib = MagicMock() + rocketlib.IInstanceBase = object # must be a real class for inheritance + rocketlib.IGlobalBase = object + rocketlib.tool_function = lambda **kwargs: lambda f: f # pass-through decorator + rocketlib.debug = lambda *a, **kw: None + rocketlib.error = lambda *a, **kw: None + rocketlib.warning = lambda *a, **kw: None + rocketlib.OPEN_MODE = MagicMock() + + depends = MagicMock() + depends.depends = lambda *a, **kw: None + + ai_common_utils = MagicMock() + ai_common_utils.normalize_tool_input = lambda args, **kw: args if isinstance(args, dict) else {} + + requests = MagicMock() + requests.exceptions = MagicMock() + requests.exceptions.Timeout = TimeoutError + requests.exceptions.RequestException = Exception + + return { + 'rocketlib': rocketlib, + 'depends': depends, + 'ai': MagicMock(), + 'ai.common': MagicMock(), + 'ai.common.utils': ai_common_utils, + 'ai.common.config': MagicMock(), + 'requests': requests, + } + + +_added_stubs = [] +for _name, _stub in _build_import_stubs().items(): + if _name not in sys.modules: + sys.modules[_name] = _stub + _added_stubs.append(_name) mod = importlib.import_module('nodes.tool_tavily_search.IInstance') +# Drop the stubs we injected so they never leak into the shared pytest session. +for _name in _added_stubs: + sys.modules.pop(_name, None) + def test_shape_results_maps_tavily_fields(): body = {'results': [{'title': 'T', 'url': 'https://example.com', 'content': 'snippet', 'score': 0.9}]} From 59fd1466c47654075078a4549161c5f7499083d2 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 14:57:53 -0700 Subject: [PATCH 10/16] chore: remove internal planning docs from the PR set The design spec and implementation plan under docs/superpowers/ are internal workflow artifacts (and carry author/personal info); they are not part of the shippable node deliverable. Remove them so the PR contains only the two nodes, the example pipeline, the node-registry doc entry, and tests. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-01-nebius-agentic-search.md | 850 ------------------ ...06-01-nebius-agentic-search-node-design.md | 171 ---- 2 files changed, 1021 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-01-nebius-agentic-search.md delete mode 100644 docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md diff --git a/docs/superpowers/plans/2026-06-01-nebius-agentic-search.md b/docs/superpowers/plans/2026-06-01-nebius-agentic-search.md deleted file mode 100644 index 3bfc2ca44..000000000 --- a/docs/superpowers/plans/2026-06-01-nebius-agentic-search.md +++ /dev/null @@ -1,850 +0,0 @@ -# Nebius Agentic Search Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a "Nebius Agentic Search" capability — a Nebius-hosted LLM that reasons and calls Tavily web search in a loop — by composing the engine's existing agent pattern with two new provider/tool nodes plus a pipeline template. - -**Architecture:** Reuse the existing `agent_deepagent` loop. Add a new `tool_tavily_search` node (Tavily web search exposed as an agent tool, cloned from `tool_exa_search`) on the tool channel, and a new `llm_nebius` node (Nebius Token Factory LLM, cloned from `llm_gmi_cloud` with a fixed base URL) on the llm channel. Ship a `.pipe` template wiring them together. - -**Tech Stack:** Python pipeline nodes; `requests` (Tavily HTTP), `langchain-openai` (Nebius, OpenAI-compatible). Synchronous. Tests via the RocketRide node test framework (`builder nodes:test` contract tests + `services.json` `test` blocks + `ROCKETRIDE_MOCK`). - -**Conventions:** -- Code and comments in English. -- Every new `.py`/`.json` file begins with the standard MIT license header used by sibling nodes — copy it verbatim from `nodes/src/nodes/tool_exa_search/__init__.py` (lines 1–24). -- Branch: `feat/nebius-agentic-search-node` (already created off `develop`). - -**Spec:** `docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md` - -**Open items to confirm during implementation:** -1. Exact Token Factory model slug for the default (plan assumes `meta-llama/Llama-3.3-70B-Instruct`) and that the deepagent JSON-envelope loop works against it. -2. Tavily API key env var name — plan uses `TAVILY_API_KEY` (mirrors `tool_exa_search` using `EXA_API_KEY`). - ---- - -## Task 1: Scaffold `tool_tavily_search` node (config + lifecycle) - -Creates the node skeleton so the engine discovers it. Tool logic is added in Task 2. - -**Files:** -- Create: `nodes/src/nodes/tool_tavily_search/__init__.py` -- Create: `nodes/src/nodes/tool_tavily_search/IGlobal.py` -- Create: `nodes/src/nodes/tool_tavily_search/IInstance.py` (stub tool in Task 2) -- Create: `nodes/src/nodes/tool_tavily_search/services.json` -- Create: `nodes/src/nodes/tool_tavily_search/requirements.txt` -- Create: `nodes/src/nodes/tool_tavily_search/tavily.svg` -- Create: `nodes/src/nodes/tool_tavily_search/README.md` - -- [ ] **Step 1: Create `__init__.py`** - -```python -# - -from .IGlobal import IGlobal -from .IInstance import IInstance - -__all__ = ['IGlobal', 'IInstance'] -``` - -- [ ] **Step 2: Create `requirements.txt`** - -``` -requests -``` - -- [ ] **Step 3: Create `IGlobal.py`** (clone of `tool_exa_search/IGlobal.py`, Exa→Tavily, env `TAVILY_API_KEY`) - -```python -# - -""" -Tavily Search tool node - global (shared) state. - -Reads the Tavily API key and search configuration from the node config. -Tool logic lives on IInstance via @tool_function. -""" - -from __future__ import annotations - -import os - -from ai.common.config import Config -from rocketlib import IGlobalBase, OPEN_MODE, error, warning - - -class IGlobal(IGlobalBase): - """Global state for tool_tavily_search.""" - - apikey: str = '' - max_results: int = 5 - search_depth: str = 'advanced' - topic: str = 'general' - - def beginGlobal(self) -> None: - if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: - return - - cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - - apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() - - if not apikey: - error('tool_tavily_search: apikey is required — set it in node config or TAVILY_API_KEY env var') - raise ValueError('tool_tavily_search: apikey is required') - - self.apikey = apikey - raw_max = cfg.get('maxResults', 5) - if raw_max is None: - raw_max = 5 - self.max_results = max(1, min(20, int(raw_max))) - self.search_depth = str(cfg.get('searchDepth') or 'advanced').strip() - self.topic = str(cfg.get('topic') or 'general').strip() - - def validateConfig(self) -> None: - try: - cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() - if not apikey: - warning('apikey is required') - except Exception as e: - warning(str(e)) - - def endGlobal(self) -> None: - self.apikey = '' -``` - -- [ ] **Step 4: Create stub `IInstance.py`** (real tool added in Task 2; stub keeps the module importable) - -```python -# - -"""Tavily Search tool node instance. Exposes tavily_search as a @tool_function.""" - -from __future__ import annotations - -from rocketlib import IInstanceBase - -from .IGlobal import IGlobal - - -class IInstance(IInstanceBase): - """Node instance exposing Tavily web search as an agent tool.""" - - IGlobal: IGlobal -``` - -- [ ] **Step 5: Create `services.json`** (clone of `tool_exa_search/services.json`, Tavily fields) - -```json -{ - "title": "Tavily Search", - "protocol": "tool_tavily_search://", - "classType": ["tool"], - "capabilities": ["invoke", "experimental"], - "register": "filter", - "node": "python", - "path": "nodes.tool_tavily_search", - "prefix": "tavily", - "icon": "tavily.svg", - "description": ["Exposes Tavily real-time web search as an agent tool.", "Performs live web searches via the Tavily API and returns structured results with titles, URLs, content snippets, and relevance scores."], - "tile": [], - "lanes": {}, - "preconfig": { - "default": "default", - "profiles": { - "default": { - "title": "Tavily Search", - "apikey": "", - "maxResults": 5, - "searchDepth": "advanced", - "topic": "general" - } - } - }, - "fields": { - "tool_tavily_search.apikey": { - "type": "string", - "title": "API Key", - "description": "Tavily API key (from https://tavily.com)", - "default": "", - "secure": true, - "ui": { "ui:widget": "ApiKeyWidget" } - }, - "tool_tavily_search.maxResults": { - "type": "integer", - "title": "Max Results", - "description": "Maximum number of search results to return (1-20)", - "default": 5, - "minimum": 1, - "maximum": 20 - }, - "tool_tavily_search.searchDepth": { - "type": "string", - "title": "Search Depth", - "description": "Tavily search depth", - "default": "advanced", - "enum": [["basic", "Basic"], ["advanced", "Advanced"]] - }, - "tool_tavily_search.topic": { - "type": "string", - "title": "Topic", - "description": "Search topic category", - "default": "general", - "enum": [["general", "General"], ["news", "News"], ["finance", "Finance"]] - } - }, - "test": { - "profiles": ["default"], - "outputs": [], - "cases": [ - { "name": "Config validation with placeholder key", "text": "test query" } - ] - }, - "shape": [ - { - "section": "Pipe", - "title": "Tavily Search", - "properties": ["type", "tool_tavily_search.apikey", "tool_tavily_search.maxResults", "tool_tavily_search.searchDepth", "tool_tavily_search.topic"] - } - ] -} -``` - -- [ ] **Step 6: Create `tavily.svg`** — author a simple single-color placeholder icon (monochrome `#000`; the build auto-tints it). Minimal valid SVG: - -```xml - -``` - -- [ ] **Step 7: Create `README.md`** — short usage doc modeled on `nodes/src/nodes/tool_exa_search`'s sibling docs: what the node does, the `TAVILY_API_KEY` env var, config fields, and that it is consumed by agents via the tool invoke channel. - -- [ ] **Step 8: Run contract test to verify the node is valid and imports** - -Run: `pytest nodes/test/test_contracts.py -k tavily -v` -Expected: PASS (services.json structure valid, `nodes.tool_tavily_search` imports). - -- [ ] **Step 9: Commit** - -```bash -git add nodes/src/nodes/tool_tavily_search -git commit -m "feat(tool_tavily_search): scaffold Tavily web-search tool node" -``` - ---- - -## Task 2: Implement the `tavily_search` tool (HTTP + retry + SSRF) - -**Files:** -- Modify: `nodes/src/nodes/tool_tavily_search/IInstance.py` -- Create: `nodes/test/test_tool_tavily_search.py` - -- [ ] **Step 1: Write failing unit tests for the pure helpers** - -Create `nodes/test/test_tool_tavily_search.py`: - -```python -# - -"""Unit tests for tool_tavily_search pure helpers (no network).""" - -import importlib - -mod = importlib.import_module('nodes.tool_tavily_search.IInstance') - - -def test_shape_results_maps_tavily_fields(): - body = { - 'results': [ - {'title': 'T', 'url': 'https://example.com', 'content': 'snippet', 'score': 0.9} - ] - } - shaped = mod._shape_results('q', body) - assert shaped['success'] is True - assert shaped['query'] == 'q' - assert shaped['num_results'] == 1 - assert shaped['results'][0]['url'] == 'https://example.com' - assert shaped['results'][0]['score'] == 0.9 - - -def test_validate_public_url_rejects_loopback(): - import pytest - with pytest.raises(ValueError): - mod._validate_public_url('http://127.0.0.1/secret') - - -def test_validate_public_url_allows_public_https(): - assert mod._validate_public_url('https://example.com/page') == 'https://example.com/page' -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest nodes/test/test_tool_tavily_search.py -v` -Expected: FAIL (`_shape_results` / `_validate_public_url` not defined). - -- [ ] **Step 3: Implement the full `IInstance.py`** (tool + helpers; HTTP/retry cloned from `tool_exa_search/IInstance.py:210-257`, SSRF from `search_exa/exa_search.py:146-168`) - -```python -# - -""" -Tavily Search tool node instance. - -Exposes ``tavily_search`` as a @tool_function for real-time web search via the Tavily API. -""" - -from __future__ import annotations - -import ipaddress -import socket -import time -from typing import Any, Dict -from urllib.parse import urlparse - -import requests - -from rocketlib import IInstanceBase, tool_function, debug - -from ai.common.utils import normalize_tool_input - -from .IGlobal import IGlobal - -TAVILY_SEARCH_URL = 'https://api.tavily.com/search' -VALID_SEARCH_DEPTHS = {'basic', 'advanced'} -VALID_TOPICS = {'general', 'news', 'finance'} - - -class IInstance(IInstanceBase): - """Node instance exposing Tavily web search as an agent tool.""" - - IGlobal: IGlobal - - @tool_function( - input_schema={ - 'type': 'object', - 'required': ['query'], - 'properties': { - 'query': {'type': 'string', 'description': 'The search query — a natural language question or keyword phrase.'}, - 'max_results': {'type': 'integer', 'description': 'Number of results to return (1-20). Defaults to the node config value.'}, - 'search_depth': {'type': 'string', 'enum': sorted(VALID_SEARCH_DEPTHS), 'description': '"basic" (fast) or "advanced" (deeper). Defaults to node config.'}, - 'topic': {'type': 'string', 'enum': sorted(VALID_TOPICS), 'description': 'Search category: "general", "news", or "finance".'}, - 'time_range': {'type': 'string', 'enum': ['day', 'week', 'month', 'year'], 'description': 'Restrict results to a recent time window.'}, - 'include_domains': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Only return results from these domains.'}, - 'exclude_domains': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Exclude results from these domains.'}, - }, - }, - output_schema={ - 'type': 'object', - 'properties': { - 'success': {'type': 'boolean'}, - 'query': {'type': 'string'}, - 'num_results': {'type': 'integer'}, - 'results': {'type': 'array', 'items': {'type': 'object'}}, - 'error': {'type': 'string'}, - }, - }, - description='Search the web in real time using Tavily. Provide a natural language query to find relevant, current web pages. Returns structured results with title, URL, content snippet, and relevance score.', - ) - def tavily_search(self, args): - """Search the web using the Tavily API.""" - args = normalize_tool_input(args, tool_name='tavily_search') - - query = (args.get('query') or '').strip() - if not query: - return {'success': False, 'query': '', 'num_results': 0, 'results': [], 'error': 'query is required and must be a non-empty string'} - - cfg = self.IGlobal - - max_results = args.get('max_results', cfg.max_results) - if isinstance(max_results, bool) or not isinstance(max_results, int): - max_results = cfg.max_results - search_depth = args.get('search_depth', cfg.search_depth) - if search_depth not in VALID_SEARCH_DEPTHS: - search_depth = cfg.search_depth - topic = args.get('topic', cfg.topic) - if topic not in VALID_TOPICS: - topic = cfg.topic - - payload: Dict[str, Any] = { - 'query': query, - 'max_results': max(1, min(20, max_results)), - 'search_depth': search_depth, - 'topic': topic, - } - time_range = args.get('time_range') - if time_range: - payload['time_range'] = str(time_range) - include_domains = args.get('include_domains') - if include_domains and isinstance(include_domains, list): - payload['include_domains'] = include_domains - exclude_domains = args.get('exclude_domains') - if exclude_domains and isinstance(exclude_domains, list): - payload['exclude_domains'] = exclude_domains - - headers = { - 'accept': 'application/json', - 'content-type': 'application/json', - 'authorization': f'Bearer {cfg.apikey}', - } - - try: - body = _request_with_retry(url=TAVILY_SEARCH_URL, headers=headers, payload=payload) - except RuntimeError as exc: - return {'success': False, 'query': query, 'num_results': 0, 'results': [], 'error': str(exc)} - - return _shape_results(query, body) - - -def _shape_results(query: str, body: Dict[str, Any]) -> Dict[str, Any]: - """Map a Tavily response body into the tool's output schema, dropping unsafe URLs.""" - results = [] - for item in body.get('results', []) or []: - url = item.get('url', '') - try: - url = _validate_public_url(url) if url else '' - except ValueError: - continue - results.append({ - 'title': item.get('title', ''), - 'url': url, - 'content': item.get('content', ''), - 'score': item.get('score'), - 'published_date': item.get('published_date'), - }) - return {'success': True, 'query': query, 'num_results': len(results), 'results': results} - - -def _validate_public_url(raw_url: str) -> str: - """Reject private/loopback/reserved hosts to prevent SSRF (clone of search_exa).""" - parsed = urlparse(raw_url) - if parsed.scheme not in ('http', 'https') or not parsed.hostname: - raise ValueError(f'Tavily returned an invalid URL: {raw_url}') - try: - addrinfo = socket.getaddrinfo(parsed.hostname, None, type=socket.SOCK_STREAM) - except socket.gaierror as e: - raise ValueError(f'Tavily returned an unresolved URL host: {parsed.hostname}') from e - for _, _, _, _, sockaddr in addrinfo: - ip = ipaddress.ip_address(sockaddr[0]) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified: - raise ValueError(f'Tavily returned a blocked URL host: {parsed.hostname}') - return raw_url - - -def _request_with_retry(*, url: str, headers: Dict[str, str], payload: Dict[str, Any], max_retries: int = 3, base_delay: float = 2.0) -> Dict[str, Any]: - """POST to the Tavily API with exponential-backoff retry on 429/5xx (clone of tool_exa_search).""" - for attempt in range(max_retries + 1): - try: - resp = requests.post(url, headers=headers, json=payload, timeout=30) - if resp.status_code == 429 or 500 <= resp.status_code < 600: - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - debug(f'Tavily transient error ({resp.status_code}), retrying in {delay}s ({attempt + 1}/{max_retries})') - time.sleep(delay) - continue - resp.raise_for_status() - resp.raise_for_status() - return resp.json() - except requests.exceptions.Timeout: - if attempt < max_retries: - time.sleep(base_delay * (2 ** attempt)) - continue - raise RuntimeError('Tavily search: request timed out after all retries') from None - except requests.RequestException as exc: - status = getattr(getattr(exc, 'response', None), 'status_code', None) - detail = f' (HTTP {status})' if status else '' - raise RuntimeError(f'Tavily search request failed{detail}: {type(exc).__name__}') from None - raise RuntimeError('Tavily search: max retries exceeded') -``` - -- [ ] **Step 4: Run the unit tests to verify they pass** - -Run: `pytest nodes/test/test_tool_tavily_search.py -v` -Expected: PASS (3 tests). - -- [ ] **Step 5: Run the contract test again** - -Run: `pytest nodes/test/test_contracts.py -k tavily -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add nodes/src/nodes/tool_tavily_search/IInstance.py nodes/test/test_tool_tavily_search.py -git commit -m "feat(tool_tavily_search): implement Tavily search tool with retry + SSRF guard" -``` - ---- - -## Task 3: Scaffold `llm_nebius` node (Nebius Token Factory LLM) - -Clone of `llm_gmi_cloud`, simplified to a fixed Token Factory base URL. - -**Files:** -- Create: `nodes/src/nodes/llm_nebius/__init__.py` -- Create: `nodes/src/nodes/llm_nebius/IInstance.py` -- Create: `nodes/src/nodes/llm_nebius/nebius.py` -- Create: `nodes/src/nodes/llm_nebius/IGlobal.py` -- Create: `nodes/src/nodes/llm_nebius/services.json` -- Create: `nodes/src/nodes/llm_nebius/requirements.txt` -- Create: `nodes/src/nodes/llm_nebius/nebius.svg` -- Create: `nodes/src/nodes/llm_nebius/README.md` - -- [ ] **Step 1: Create `requirements.txt`** - -``` -langchain-openai -openai -``` - -- [ ] **Step 2: Create `IInstance.py`** (identical to `llm_gmi_cloud/IInstance.py`) - -```python -# - -from ai.common.llm_base import LLMBase - - -class IInstance(LLMBase): - pass -``` - -- [ ] **Step 3: Create `__init__.py`** (exports `getChat`, like `llm_gmi_cloud/__init__.py`) - -```python -# - -from .IGlobal import IGlobal -from .IInstance import IInstance - - -def getChat(): - """Get the Chat class from the module.""" - from .nebius import Chat - - return Chat - - -__all__ = ['IGlobal', 'IInstance', 'getChat'] -``` - -- [ ] **Step 4: Create `nebius.py`** (clone of `llm_gmi_cloud/gmi_cloud.py`, fixed Token Factory base URL) - -```python -# - -"""Nebius Token Factory binding for the ChatLLM (OpenAI-compatible).""" - -from typing import Any, Dict -from openai import AuthenticationError, APIError, RateLimitError, APIConnectionError -from ai.common.chat import ChatBase -from ai.common.config import Config -from langchain_openai import ChatOpenAI - -NEBIUS_BASE_URL = 'https://api.tokenfactory.nebius.com/v1/' - - -class Chat(ChatBase): - """Creates a Nebius Token Factory chat bot.""" - - _llm: ChatOpenAI - - def __init__(self, provider: str, connConfig: Dict[str, Any], bag: Dict[str, Any]): - super().__init__(provider, connConfig, bag) - - config = Config.getNodeConfig(provider, connConfig) - - # Dummy placeholder so the client initialises before a key is saved. - apikey = config.get('apikey') or 'sk-dummy' - - self._llm = ChatOpenAI( - model=self._model, - base_url=NEBIUS_BASE_URL, - api_key=apikey, - temperature=0, - max_tokens=self._modelOutputTokens, - ) - - bag['chat'] = self - - def is_retryable_error(self, error): - return isinstance(error, (RateLimitError, APIConnectionError)) - - def map_exception(self, error): - if isinstance(error, AuthenticationError): - return ValueError('Invalid Nebius API key.') - elif isinstance(error, RateLimitError): - return ValueError(f'Nebius rate limit: {error}') - elif isinstance(error, APIConnectionError): - return ValueError('Failed to connect to the Nebius Token Factory API.') - elif isinstance(error, APIError): - return ValueError(f'Nebius API error: {error}') - else: - return super().map_exception(error) -``` - -- [ ] **Step 5: Create `IGlobal.py`** (simplified clone of `llm_gmi_cloud/IGlobal.py`; base URL is fixed, so no serverbase/SSRF handling) - -```python -# - -import os -from typing import Optional -from rocketlib import IGlobalBase, warning -from ai.common.config import Config -from ai.common.chat import ChatBase - - -class IGlobal(IGlobalBase): - """Global handler for the Nebius Token Factory LLM node.""" - - _chat: Optional[ChatBase] = None - - _VALIDATION_PROMPT = 'Hi' - _BASE_URL = 'https://api.tokenfactory.nebius.com/v1/' - - def _resolve_apikey(self, config) -> str: - return str(config.get('apikey') or os.environ.get('NEBIUS_API_KEY', '')).strip() - - def validateConfig(self): - """Probe the model with a 1-token request to validate key + model at save time.""" - from depends import depends # type: ignore - - requirements = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'requirements.txt') - depends(requirements) - - try: - from openai import OpenAI, APIStatusError, OpenAIError, AuthenticationError, RateLimitError, APIConnectionError - - config = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - apikey = self._resolve_apikey(config) - model = config.get('model') - if not model or not apikey: - return - try: - client = OpenAI(api_key=apikey, base_url=self._BASE_URL) - client.chat.completions.create( - model=model, - messages=[{'role': 'user', 'content': self._VALIDATION_PROMPT}], - max_tokens=1, - ) - except RateLimitError: - return - except APIStatusError as e: - status = getattr(e, 'status_code', None) or getattr(e, 'status', None) - if status == 429: - return - warning(f'Nebius validation error {status}: {e}') - return - except (AuthenticationError, APIConnectionError, OpenAIError) as e: - warning(str(e)) - return - except Exception as e: - warning(str(e)) - - def beginGlobal(self): - """Initialize the Nebius chat client.""" - from depends import depends # type: ignore - - requirements = os.path.dirname(os.path.realpath(__file__)) + '/requirements.txt' - depends(requirements) - - from .nebius import Chat - - bag = self.IEndpoint.endpoint.bag - config = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - if not self._resolve_apikey(config): - raise ValueError('Nebius API key is required.') - self._chat = Chat(self.glb.logicalType, config, bag) - - def endGlobal(self): - self._chat = None -``` - -- [ ] **Step 6: Create `services.json`** (curated Token Factory profiles; default = Llama 3.3 70B) - -```json -{ - "title": "Nebius", - "protocol": "llm_nebius://", - "classType": ["llm"], - "capabilities": ["invoke"], - "register": "filter", - "node": "python", - "path": "nodes.llm_nebius", - "prefix": "llm", - "icon": "nebius.svg", - "documentation": "https://docs.rocketride.org", - "description": ["Connects to Nebius Token Factory's OpenAI-compatible inference API.", "Hosts open models (Llama, Qwen, DeepSeek) for reasoning, generation, and tool-calling. Used as an `llm` invoke connection by agents, including Nebius Agentic Search."], - "tile": ["Model: ${parameters.llm_nebius.profile}"], - "lanes": { "questions": ["answers"] }, - "preconfig": { - "default": "llama-3-3-70b", - "profiles": { - "llama-3-3-70b": { - "title": "Llama 3.3 70B Instruct", - "model": "meta-llama/Llama-3.3-70B-Instruct", - "apikey": "", - "modelTotalTokens": 131072 - }, - "qwen3-235b": { - "title": "Qwen3 235B", - "model": "Qwen/Qwen3-235B-A22B", - "apikey": "", - "modelTotalTokens": 131072 - }, - "deepseek-v3": { - "title": "DeepSeek V3", - "model": "deepseek-ai/DeepSeek-V3", - "apikey": "", - "modelTotalTokens": 131072 - }, - "custom": { - "model": "", - "apikey": "", - "modelTotalTokens": 131072 - } - } - }, - "fields": { - "model": { - "type": "string", - "title": "Model", - "description": "Nebius Token Factory model id (e.g. meta-llama/Llama-3.3-70B-Instruct). Full list: https://tokenfactory.nebius.com/models" - }, - "modelTotalTokens": { "type": "number", "title": "Tokens", "description": "Total Tokens" }, - "llm_nebius.llama-3-3-70b": { "object": "llama-3-3-70b", "properties": ["llm.cloud.apikey"] }, - "llm_nebius.qwen3-235b": { "object": "qwen3-235b", "properties": ["llm.cloud.apikey"] }, - "llm_nebius.deepseek-v3": { "object": "deepseek-v3", "properties": ["llm.cloud.apikey"] }, - "llm_nebius.custom": { "object": "custom", "properties": ["model", "modelTotalTokens", "llm.cloud.apikey"] }, - "llm_nebius.profile": { - "title": "Model", - "description": "Nebius Token Factory model", - "type": "string", - "default": "llama-3-3-70b", - "enum": ["*>preconfig.profiles.*.title"], - "conditional": [ - { "value": "llama-3-3-70b", "properties": ["llm_nebius.llama-3-3-70b"] }, - { "value": "qwen3-235b", "properties": ["llm_nebius.qwen3-235b"] }, - { "value": "deepseek-v3", "properties": ["llm_nebius.deepseek-v3"] }, - { "value": "custom", "properties": ["llm_nebius.custom"] } - ] - } - }, - "shape": [ - { "section": "Pipe", "title": "Nebius", "properties": ["llm_nebius.profile"] } - ], - "test": { - "profiles": ["llama-3-3-70b"], - "outputs": ["answers"], - "cases": [ - { "name": "LLM returns mock response", "text": "What is 2+2?", "expect": { "answers": { "contains": "Mock LLM response" } } } - ] - } -} -``` - -- [ ] **Step 7: Create `nebius.svg`** — monochrome placeholder icon (`#000`), same approach as Step 6 of Task 1. - -```xml - -``` - -- [ ] **Step 8: Create `README.md`** — short doc modeled on `llm_gmi_cloud`'s description: Token Factory base URL, `NEBIUS_API_KEY` env var, model profiles, and that it is used as an `llm` channel for agents (Nebius Agentic Search). - -- [ ] **Step 9: Run contract test** - -Run: `pytest nodes/test/test_contracts.py -k nebius -v` -Expected: PASS (services.json valid, `nodes.llm_nebius` imports, `getChat` present). - -- [ ] **Step 10: Commit** - -```bash -git add nodes/src/nodes/llm_nebius -git commit -m "feat(llm_nebius): add Nebius Token Factory LLM provider node" -``` - ---- - -## Task 4: Verify `llm_nebius` integration test (mock-backed) - -The `services.json` `test` block added in Task 3 uses the existing `langchain_openai` mock (`nodes/test/mocks/langchain_openai`) which returns "Mock LLM response" — same path as `llm_gmi_cloud`. - -- [ ] **Step 1: Run the full node test for `llm_nebius` under mock** - -Run: `builder nodes:test-full --pattern="llm_nebius"` -Expected: PASS — the `llama-3-3-70b` profile returns an answer containing "Mock LLM response". - -- [ ] **Step 2: If the mock does not auto-apply** (the node uses `ChatOpenAI` exactly like `llm_gmi_cloud`, so it should), confirm no new mock is needed by diffing the import surface against `llm_gmi_cloud`. No code change expected. - -- [ ] **Step 3: Commit (only if any adjustment was required)** - -```bash -git add -A && git commit -m "test(llm_nebius): confirm mock-backed integration test passes" -``` - ---- - -## Task 5: Pipeline template "Nebius Agentic Search" - -Wire the two new nodes into the existing `agent_deepagent` and ship a `.pipe` example. - -**Files:** -- Create: `examples/nebius-agentic-search.pipe` -- (Optional) Modify: `packages/shared-ui/src/components/canvas/templates/templates.json` - -**Reference:** `pipelines/git_agent_example.pipe` — the same chat→agent←llm+tool wiring (it uses `agent_langchain` + `llm_gemini` + `tool_git`). Mirror its `components[]` + `input[]` lane/channel structure. - -- [ ] **Step 1: Author `examples/nebius-agentic-search.pipe`** with four components: - - `chat` trigger (Source) — copy the `chat_1` component from `git_agent_example.pipe`. - - `agent_deepagent` — name "Nebius Agentic Search"; `config.instructions`: "You are an agentic web-research assistant. Use the tavily_search tool to find current information, refine queries when results are weak, cite sources, and answer concisely."; receives the chat trigger on the `questions` lane. - - `llm_nebius` — connected to the agent's `llm` invoke channel (default profile `llama-3-3-70b`). - - `tool_tavily_search` — connected to the agent's `tool` invoke channel. - - Match `git_agent_example.pipe`'s JSON shape exactly (component `id`/`provider`/`config`/`ui.position`, and `input[]` entries declaring the lane/channel connections). Easiest reliable path: open the canvas, drag these four nodes, wire chat→questions→agent, llm_nebius→agent (llm channel), tool_tavily_search→agent (tool channel), then **Export** the pipeline and save the exported JSON to `examples/nebius-agentic-search.pipe`. - -- [ ] **Step 2: Validate the template imports** - -Import `examples/nebius-agentic-search.pipe` into the canvas (or run any existing pipeline-import validation in `packages/server/test/pipelines`). Expected: loads with all four nodes wired, no validation errors. - -- [ ] **Step 3: (Optional) Register the template** in `packages/shared-ui/src/components/canvas/templates/templates.json` so it appears as a one-click "Nebius Agentic Search" card. Follow the existing entries' shape in that file. - -- [ ] **Step 4: Commit** - -```bash -git add examples/nebius-agentic-search.pipe packages/shared-ui/src/components/canvas/templates/templates.json -git commit -m "feat(examples): add Nebius Agentic Search pipeline template" -``` - ---- - -## Task 6: Docs + final verification - -**Files:** -- Modify: `docs/README-nodes.md` - -- [ ] **Step 1: Add the two nodes to `docs/README-nodes.md`** — add `tool_tavily_search` near the AI/search section and `llm_nebius` to the "LLM Providers" table, each with a one-line description. - -- [ ] **Step 2: Run the full contract test suite** - -Run: `builder nodes:test` -Expected: PASS for all nodes (including the two new ones). - -- [ ] **Step 3: Run the focused integration tests** - -Run: `builder nodes:test-full --pattern="tavily" && builder nodes:test-full --pattern="llm_nebius"` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add docs/README-nodes.md -git commit -m "docs(nodes): document tool_tavily_search and llm_nebius" -``` - ---- - -## Verification checklist (whole feature) - -- [ ] `pytest nodes/test/test_contracts.py -k "tavily or nebius" -v` passes. -- [ ] `pytest nodes/test/test_tool_tavily_search.py -v` passes. -- [ ] `builder nodes:test-full --pattern="llm_nebius"` returns "Mock LLM response". -- [ ] The `.pipe` template imports cleanly with all four nodes wired. -- [ ] With real `NEBIUS_API_KEY` + `TAVILY_API_KEY`, a question to the template produces a cited answer and at least one Tavily call (manual smoke test). diff --git a/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md b/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md deleted file mode 100644 index 03ff2c24c..000000000 --- a/docs/superpowers/specs/2026-06-01-nebius-agentic-search-node-design.md +++ /dev/null @@ -1,171 +0,0 @@ -# Nebius Agentic Search — Design Spec - -**Date:** 2026-06-01 -**Author:** Po-Hsu (pohsu.lien@rocketride.ai) -**Status:** Draft — pending review -**Related:** GTM-driven new nodes list (Nebius sponsorship, June 18 event). No GitHub issue yet. - ---- - -## 1. Goal - -Deliver a **Nebius Agentic Search** capability: a pipeline where a Nebius-hosted LLM -reasons about a user question, decides what to search, calls Tavily web search one or -more times (refining queries as needed), and synthesizes a grounded, cited answer. - -This showcases the full Nebius stack — **Nebius Token Factory (reasoning) + Tavily -(real-time search)** — where Tavily is the agentic-search capability Nebius acquired. - -## 2. Approach: compose existing infrastructure (Shape B) - -Decision: build the feature the way every existing agent in this codebase is built — -an **agent node driving a wired LLM channel + wired tool channel** — rather than a -self-contained node with a bundled LLM and a hand-rolled tool loop. - -**Evidence for this choice:** all four agent nodes (`agent_crewai`, `agent_deepagent`, -`agent_langchain`, `agent_rocketride`) are `classType: ["agent","tool"]` and require an -external `"llm"` invoke channel; `llm_*` nodes are single-purpose inference providers; -`tool_*` nodes are single-purpose tools; `search_exa` is single-shot with no loop. There -is **zero precedent** for a node that bundles its own LLM client and runs an internal -tool-calling loop. Composing existing infra reuses a battle-tested loop and matches the -engine's deliberate architecture (text-only LLM seam + JSON-envelope tool protocol — -documented in `agent_langchain/README.md` and `agent_deepagent/deepagent.py`). - -## 3. Background / Facts - -- **Nebius Token Factory** (formerly AI Studio): OpenAI-compatible inference at - `https://api.tokenfactory.nebius.com/v1/`, Bearer auth. Hosts 60+ open models - (Llama 3.x, Qwen3/2.5, DeepSeek V3, GPT-OSS, Mistral). "Using Nebius" = using this - inference service; Nebius does not train a proprietary foundation model. -- **Tavily** (Nebius-acquired): "the web access layer for agents" — `POST - https://api.tavily.com/search`, Bearer `tvly-...`. Response: - `{ query, results:[{title,url,content,score}], ... }`. -- **"Agentic search" definition** (both vendors): the *search/retrieval layer that - agents call*, distinct from the reasoning LLM. The agency comes from the LLM driving - the search — which is exactly what the agent loop provides. - -## 4. Deliverables - -| # | Item | New/Reuse | Mirrors | -| --- | --- | --- | --- | -| 1 | `tool_tavily_search` node | **NEW** (core work) | `tool_exa_search` (Exa → Tavily) | -| 2 | `llm_nebius` node | **NEW** (branding) | `llm_gmi_cloud` (base_url → Token Factory) | -| 3 | Agentic loop | **REUSE** `agent_deepagent` (default) | — | -| 4 | "Nebius Agentic Search" pipeline template | **NEW** | `examples/*.pipe`, `canvas/templates/templates.json` | - -`llm_nebius` is recommended for the on-brand "Nebius" palette entry, but is optional — -the existing `llm_openai_api` node already accepts a custom `base_url` -(`llm_openai_api/services.json:99`) and can point at Token Factory as a zero-new-node -fallback. - -### 4.1 `tool_tavily_search` (new tool node) - -Clone of `tool_exa_search`. Files: `__init__.py`, `IGlobal.py`, `IInstance.py`, -`services.json`, `requirements.txt` (`requests`), `tavily.svg`, `README.md`. - -- `classType: ["tool"]`, `capabilities: ["invoke"]`, `register: "filter"`, - `lanes: {}` (discovered via the control-plane invoke seam, like `tool_exa_search`). -- One `@tool_function`: - `tavily_search(query, search_depth?, max_results?, topic?, time_range?, - include_domains?, exclude_domains?)` — input schema mirrors `tool_exa_search` - adapted to Tavily params. Output: `{ success, query, num_results, results:[{title, - url, content, score, published_date?}], error? }`. -- Implementation: `POST https://api.tavily.com/search`, Bearer auth, 30s timeout, - exponential-backoff retry on 429/5xx (clone `tool_exa_search/IInstance.py:210-257`), - SSRF guard on result URLs (clone `search_exa/exa_search.py:146-168`). -- API key: `secure`, `ApiKeyWidget`. Resolution: node config → connConfig → - `os.environ["ROCKETRIDE_TAVILY_KEY"]`. - -### 4.2 `llm_nebius` (new LLM provider node) - -Clone of `llm_gmi_cloud`. Files: `__init__.py` (exports `getChat`), `IGlobal.py`, -`IInstance.py`, `nebius.py` (`Chat(ChatBase)`), `services.json`, `requirements.txt` -(`langchain-openai`), `nebius.svg`, `README.md`. - -- `classType: ["llm"]`, used by agents via the `llm` invoke channel. -- `Chat(ChatBase)` builds - `langchain_openai.ChatOpenAI(model=, base_url="https://api.tokenfactory.nebius.com/v1/", - api_key=, max_tokens=...)` — same shape as `llm_gmi_cloud/gmi_cloud.py:58-63`. -- Default model: **`meta-llama/Llama-3.3-70B-Instruct`** (strong tool-calling). **Open - item:** confirm exact Token Factory slug + that the agent loop works against it. -- API key: `secure`, `ApiKeyWidget`. Resolution: node config → connConfig → - `os.environ["ROCKETRIDE_NEBIUS_KEY"]`. - -### 4.3 Reuse `agent_deepagent` - -Drives the reasoning/tool loop. `classType: ["agent","tool"]`, invoke channels -`llm: {min 1}`, `tool: {min 0}` (`agent_deepagent/services.agent.json:13-24`). No -changes required. (`agent_rocketride` "Wave" is a viable alternative driver — it batches -parallel tool calls, useful for fan-out multi-source search — but `agent_deepagent` is -the default for simplicity.) - -### 4.4 Pipeline template - -A `.pipe` example (e.g. `examples/nebius-agentic-search.pipe`) wiring -`llm_nebius` (llm channel) + `tool_tavily_search` (tool channel) + `agent_deepagent`, -with a question/answer endpoint. Optionally register it in -`packages/shared-ui/src/components/canvas/templates/templates.json` so it appears as a -one-click "Nebius Agentic Search" template in the canvas. - -## 5. Data flow - -``` -question ─▶ agent_deepagent (questions lane) - │ LangGraph loop (existing): - │ ├─ call host LLM ──▶ llm_nebius ──▶ Token Factory (Nebius reasoning) - │ ├─ JSON-envelope tool_call ──▶ tool_tavily_search ──▶ api.tavily.com - │ └─ feed result back, repeat until {"type":"final"} - ▼ - answers lane: synthesized, cited answer -``` - -The loop, host-LLM routing, tool discovery/invocation, and SSE events are all provided -by the existing `agent_deepagent` driver (`deepagent.py`) + `AgentBase` -(`packages/ai/src/ai/common/agent/agent.py`). No loop code is written by us. - -## 6. Error handling - -Most behavior is inherited from the existing agent loop. New-code responsibilities: - -| Condition | Behavior | -| --- | --- | -| Missing Tavily / Nebius key | `IGlobal.beginGlobal()` raises node-specific error; `validateConfig()` warns (matches `tool_exa_search`/`llm_gmi_cloud`). | -| Tavily 401 / Nebius 401 | Provider-prefixed `PermissionError`. | -| Tavily 429 / 5xx | Exponential-backoff retry, then a clear `{success:false, error}` (tool layer must not crash the loop). | -| Timeout / connection error | Mapped to clear errors; tool returns `success:false`. | -| SSRF (private/loopback URLs) | Dropped via `_validate_public_url`. | -| Empty query | Tool returns `success:false` with message (matches `tool_exa_search`). | - -## 7. Testing - -- **`tool_tavily_search`**: `services.json` `test` block (per `docs/README-node-testing.md`), - `requires: ["ROCKETRIDE_TAVILY_KEY"]` for full runs + `ROCKETRIDE_MOCK` mock path - (mock in `nodes/test/mocks/`). Cases: valid query → results notEmpty; empty query → - `success:false`. Pure helpers (retry, URL validation) unit-tested. -- **`llm_nebius`**: `services.json` `test` block, `requires: ["ROCKETRIDE_NEBIUS_KEY"]`, - mock path. Case: simple prompt → answer notEmpty (mirrors `llm_gmi_cloud` test). -- **Contract tests** (`builder nodes:test`) validate both nodes' `services.json` + - module import. -- **Integration**: a smoke test of the template pipeline (mocked) confirming an - end-to-end question → answer with ≥1 Tavily call. - -## 8. Forward compatibility ("leave room") - -- `llm_nebius`: profiles let new Token Factory models be added without code changes. -- `tool_tavily_search`: Tavily also offers extract / crawl / research endpoints — each - can be added later as an additional `@tool_function` on the same node, no rewrite. -- The template composes standard nodes, so swapping the driver (deepagent ↔ Wave) or the - LLM model is pure configuration. - -## 9. Dependencies - -`requests` (Tavily tool), `langchain-openai` (Nebius LLM) — both already used in the -repo. Synchronous; no homomorphic crypto, no Python-3.11 constraint, no async bridge. - -## 10. Open items - -1. Confirm Token Factory model slug for the default (`meta-llama/Llama-3.3-70B-Instruct`?) - and that the agent loop's JSON-envelope protocol works reliably against it. -2. Build branded `llm_nebius`, or ship with existing `llm_openai_api`? (Spec assumes - `llm_nebius` for GTM branding.) -3. Default driver in the template: `agent_deepagent` (assumed) vs `agent_rocketride` Wave. From 3781aba079a0c5cd149d3a96cf2f62aa7b9f4816 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 15:28:23 -0700 Subject: [PATCH 11/16] fix(nebius_search): gate dynamic node tests on API keys; address review nits CI 'Test' step ran the services.json dynamic tests for both nodes with no API key (no NEBIUS_API_KEY/TAVILY_API_KEY in CI), and beginGlobal raises when the key is missing -> RuntimeError. Add 'requires' to both test blocks so the dynamic test is filtered out at collection when the key env var is absent (matches llm_openai/llm_ollama/rerank_cohere). Also addresses CodeRabbit review: - test_shape_results: stub socket.getaddrinfo so it is network-independent. - examples/.pipe: drop redundant config.type (server derives type from provider). Co-Authored-By: Claude Opus 4.8 --- examples/nebius-agentic-search.pipe | 1 - nodes/src/nodes/llm_nebius/services.json | 1 + nodes/src/nodes/tool_tavily_search/services.json | 1 + nodes/test/test_tool_tavily_search.py | 11 ++++++++++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/nebius-agentic-search.pipe b/examples/nebius-agentic-search.pipe index cbe633575..fb8d094e8 100644 --- a/examples/nebius-agentic-search.pipe +++ b/examples/nebius-agentic-search.pipe @@ -91,7 +91,6 @@ "provider": "tool_tavily_search", "name": "Tavily Search", "config": { - "type": "tool_tavily_search", "apikey": "${TAVILY_API_KEY}", "maxResults": 5, "searchDepth": "advanced", diff --git a/nodes/src/nodes/llm_nebius/services.json b/nodes/src/nodes/llm_nebius/services.json index 9601278d0..3493e0b15 100644 --- a/nodes/src/nodes/llm_nebius/services.json +++ b/nodes/src/nodes/llm_nebius/services.json @@ -177,6 +177,7 @@ } ], "test": { + "requires": ["NEBIUS_API_KEY"], "profiles": ["llama-3-3-70b"], "outputs": ["answers"], "cases": [ diff --git a/nodes/src/nodes/tool_tavily_search/services.json b/nodes/src/nodes/tool_tavily_search/services.json index ed0a83125..bc01eb6f2 100644 --- a/nodes/src/nodes/tool_tavily_search/services.json +++ b/nodes/src/nodes/tool_tavily_search/services.json @@ -121,6 +121,7 @@ // so this case exercises config validation without live API calls. // "test": { + "requires": ["TAVILY_API_KEY"], "profiles": ["default"], "outputs": [], "cases": [ diff --git a/nodes/test/test_tool_tavily_search.py b/nodes/test/test_tool_tavily_search.py index 7f194d48b..335f0fbc0 100644 --- a/nodes/test/test_tool_tavily_search.py +++ b/nodes/test/test_tool_tavily_search.py @@ -97,7 +97,16 @@ def _build_import_stubs(): sys.modules.pop(_name, None) -def test_shape_results_maps_tavily_fields(): +def test_shape_results_maps_tavily_fields(monkeypatch): + import socket + + # _shape_results validates each URL via _validate_public_url -> socket.getaddrinfo; + # stub DNS so the test stays network-independent (offline CI safe). + monkeypatch.setattr( + socket, + 'getaddrinfo', + lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 0, '', ('93.184.216.34', 0))], + ) body = {'results': [{'title': 'T', 'url': 'https://example.com', 'content': 'snippet', 'score': 0.9}]} shaped = mod._shape_results('q', body) assert shaped['success'] is True From 30d8c747b6d4b8766392eb75abc4f9796f415fda Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 15:54:36 -0700 Subject: [PATCH 12/16] chore: re-trigger CI (unrelated flaky TS client test on Ubuntu/Windows) No code change. The previous run's failure was tests/RocketRideClient.test.ts (client-typescript disconnect teardown leak), unrelated to this PR's Python nodes; macOS passed. Empty commit to re-run CI. Co-Authored-By: Claude Opus 4.8 From 44c6e1a91de5a477e87f0c11e209a992c2356c75 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 16:11:52 -0700 Subject: [PATCH 13/16] docs(tool_tavily_search): align test-block comment with the requires gate The 'test' case comment claimed it runs keyless to exercise validateConfig, but the dynamic test builds+runs the node (calls beginGlobal, which needs a key) and the block now carries requires:[TAVILY_API_KEY]. Update the comment to state the case is skipped without the key, and rename it accordingly. Addresses CodeRabbit. (Contract tests still cover structure keyless.) Co-Authored-By: Claude Opus 4.8 --- nodes/src/nodes/tool_tavily_search/services.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nodes/src/nodes/tool_tavily_search/services.json b/nodes/src/nodes/tool_tavily_search/services.json index bc01eb6f2..9a912e945 100644 --- a/nodes/src/nodes/tool_tavily_search/services.json +++ b/nodes/src/nodes/tool_tavily_search/services.json @@ -117,8 +117,10 @@ }, // // Test configuration for automated node testing. - // No apikey is supplied — validateConfig emits a warning (not an error), - // so this case exercises config validation without live API calls. + // Gated on TAVILY_API_KEY: the dynamic test builds a pipeline and runs the + // node, which calls beginGlobal() and therefore needs a key, so the case is + // skipped when the key is absent (e.g. CI without the secret). Node + // structure is still validated by the keyless contract tests. // "test": { "requires": ["TAVILY_API_KEY"], @@ -126,7 +128,7 @@ "outputs": [], "cases": [ { - "name": "Config validation with placeholder key", + "name": "Tavily search smoke test (requires key)", "text": "test query" } ] From 78fd9a104526816c257c1e0828560de772daf8b5 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 16:20:37 -0700 Subject: [PATCH 14/16] refactor(tool_tavily): rename node tool_tavily_search -> tool_tavily Drop the redundant _search suffix from the node id/provider (the agent tool method is still tavily_search). Updates the directory, services.json protocol/path/field keys, IGlobal messages, README, the example pipeline (provider + component id), the node-registry doc, and the unit-test module. Co-Authored-By: Claude Opus 4.8 --- nodes/src/nodes/{tool_tavily_search => tool_tavily}/IGlobal.py | 0 nodes/src/nodes/{tool_tavily_search => tool_tavily}/IInstance.py | 0 nodes/src/nodes/{tool_tavily_search => tool_tavily}/README.md | 0 nodes/src/nodes/{tool_tavily_search => tool_tavily}/__init__.py | 0 .../nodes/{tool_tavily_search => tool_tavily}/requirements.txt | 0 nodes/src/nodes/{tool_tavily_search => tool_tavily}/services.json | 0 nodes/src/nodes/{tool_tavily_search => tool_tavily}/tavily.svg | 0 nodes/test/{test_tool_tavily_search.py => test_tool_tavily.py} | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/IGlobal.py (100%) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/IInstance.py (100%) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/README.md (100%) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/__init__.py (100%) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/requirements.txt (100%) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/services.json (100%) rename nodes/src/nodes/{tool_tavily_search => tool_tavily}/tavily.svg (100%) rename nodes/test/{test_tool_tavily_search.py => test_tool_tavily.py} (100%) diff --git a/nodes/src/nodes/tool_tavily_search/IGlobal.py b/nodes/src/nodes/tool_tavily/IGlobal.py similarity index 100% rename from nodes/src/nodes/tool_tavily_search/IGlobal.py rename to nodes/src/nodes/tool_tavily/IGlobal.py diff --git a/nodes/src/nodes/tool_tavily_search/IInstance.py b/nodes/src/nodes/tool_tavily/IInstance.py similarity index 100% rename from nodes/src/nodes/tool_tavily_search/IInstance.py rename to nodes/src/nodes/tool_tavily/IInstance.py diff --git a/nodes/src/nodes/tool_tavily_search/README.md b/nodes/src/nodes/tool_tavily/README.md similarity index 100% rename from nodes/src/nodes/tool_tavily_search/README.md rename to nodes/src/nodes/tool_tavily/README.md diff --git a/nodes/src/nodes/tool_tavily_search/__init__.py b/nodes/src/nodes/tool_tavily/__init__.py similarity index 100% rename from nodes/src/nodes/tool_tavily_search/__init__.py rename to nodes/src/nodes/tool_tavily/__init__.py diff --git a/nodes/src/nodes/tool_tavily_search/requirements.txt b/nodes/src/nodes/tool_tavily/requirements.txt similarity index 100% rename from nodes/src/nodes/tool_tavily_search/requirements.txt rename to nodes/src/nodes/tool_tavily/requirements.txt diff --git a/nodes/src/nodes/tool_tavily_search/services.json b/nodes/src/nodes/tool_tavily/services.json similarity index 100% rename from nodes/src/nodes/tool_tavily_search/services.json rename to nodes/src/nodes/tool_tavily/services.json diff --git a/nodes/src/nodes/tool_tavily_search/tavily.svg b/nodes/src/nodes/tool_tavily/tavily.svg similarity index 100% rename from nodes/src/nodes/tool_tavily_search/tavily.svg rename to nodes/src/nodes/tool_tavily/tavily.svg diff --git a/nodes/test/test_tool_tavily_search.py b/nodes/test/test_tool_tavily.py similarity index 100% rename from nodes/test/test_tool_tavily_search.py rename to nodes/test/test_tool_tavily.py From c3d16655336491eb2a6203687885dc1eb288d70d Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Mon, 1 Jun 2026 16:30:36 -0700 Subject: [PATCH 15/16] refactor(tool_tavily): drop redundant "search" from name and tool method Rename the agent tool method tavily_search -> tavily and the display name "Tavily Search" -> "Tavily" (Tavily is a search tool, so the suffix is redundant). The agent trace now shows tool_tavily_1.tavily. Also completes the node-id rename that 78fd9a10 captured only as file moves: this carries the in-file content edits (services.json protocol/path/field keys, IGlobal messages, README, example pipeline, node-registry doc, unit-test module) from tool_tavily_search -> tool_tavily. Co-Authored-By: Claude Opus 4.8 --- docs/README-nodes.md | 4 ++-- examples/nebius-agentic-search.pipe | 12 ++++++------ nodes/src/nodes/tool_tavily/IGlobal.py | 8 ++++---- nodes/src/nodes/tool_tavily/IInstance.py | 18 +++++++++--------- nodes/src/nodes/tool_tavily/README.md | 2 +- nodes/src/nodes/tool_tavily/services.json | 22 +++++++++++----------- nodes/test/test_tool_tavily.py | 6 +++--- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/README-nodes.md b/docs/README-nodes.md index 9d576c3bc..fdb99e959 100644 --- a/docs/README-nodes.md +++ b/docs/README-nodes.md @@ -106,9 +106,9 @@ Tool nodes (`classType: ["tool"]`) expose capabilities to agents via the control | Node | Description | Documentation | | -------------------- | ------------------------------------ | ------------------------------------------------------------ | -| `tool_tavily_search` | Tavily real-time web search for agents | [README](../nodes/src/nodes/tool_tavily_search/README.md) | +| `tool_tavily` | Tavily real-time web search for agents | [README](../nodes/src/nodes/tool_tavily/README.md) | -The `tool_tavily_search` node pairs with `llm_nebius` and `agent_deepagent` to build Nebius Agentic Search — see `examples/nebius-agentic-search.pipe`. +The `tool_tavily` node pairs with `llm_nebius` and `agent_deepagent` to build Nebius Agentic Search — see `examples/nebius-agentic-search.pipe`. ## Internal diff --git a/examples/nebius-agentic-search.pipe b/examples/nebius-agentic-search.pipe index fb8d094e8..9ca7a93bd 100644 --- a/examples/nebius-agentic-search.pipe +++ b/examples/nebius-agentic-search.pipe @@ -32,7 +32,7 @@ "config": { "instructions": [ "You are an agentic web-research assistant.", - "Use the tavily_search tool to find current information; refine your query and search again when results are weak.", + "Use the tavily tool to find current information; refine your query and search again when results are weak.", "Cite the source URLs you used and answer concisely." ], "parameters": {} @@ -87,9 +87,9 @@ ] }, { - "id": "tool_tavily_search_1", - "provider": "tool_tavily_search", - "name": "Tavily Search", + "id": "tool_tavily_1", + "provider": "tool_tavily", + "name": "Tavily", "config": { "apikey": "${TAVILY_API_KEY}", "maxResults": 5, @@ -142,7 +142,7 @@ } ], "source": "chat_1", - "project_id": "b7d3e1a2-9f4c-4b8e-a5d6-2c8f1e0b3a7d", + "project_id": "c5ba857f-30ff-4f3d-b5bf-9ee90d3ee33b", "viewport": { "x": 15.5, "y": -120.9, @@ -150,4 +150,4 @@ }, "version": 1, "docRevision": 1 -} +} \ No newline at end of file diff --git a/nodes/src/nodes/tool_tavily/IGlobal.py b/nodes/src/nodes/tool_tavily/IGlobal.py index e634e856c..10bb67c93 100644 --- a/nodes/src/nodes/tool_tavily/IGlobal.py +++ b/nodes/src/nodes/tool_tavily/IGlobal.py @@ -24,7 +24,7 @@ # ============================================================================= """ -Tavily Search tool node - global (shared) state. +Tavily tool node - global (shared) state. Reads the Tavily API key and search configuration from the node config. Tool logic lives on IInstance via @tool_function. @@ -39,7 +39,7 @@ class IGlobal(IGlobalBase): - """Global state for tool_tavily_search.""" + """Global state for tool_tavily.""" apikey: str = '' max_results: int = 5 @@ -55,8 +55,8 @@ def beginGlobal(self) -> None: apikey = str(cfg.get('apikey') or os.environ.get('TAVILY_API_KEY', '')).strip() if not apikey: - error('tool_tavily_search: apikey is required — set it in node config or TAVILY_API_KEY env var') - raise ValueError('tool_tavily_search: apikey is required') + error('tool_tavily: apikey is required — set it in node config or TAVILY_API_KEY env var') + raise ValueError('tool_tavily: apikey is required') self.apikey = apikey raw_max = cfg.get('maxResults', 5) diff --git a/nodes/src/nodes/tool_tavily/IInstance.py b/nodes/src/nodes/tool_tavily/IInstance.py index 69e778529..0015fc28d 100644 --- a/nodes/src/nodes/tool_tavily/IInstance.py +++ b/nodes/src/nodes/tool_tavily/IInstance.py @@ -24,9 +24,9 @@ # ============================================================================= """ -Tavily Search tool node instance. +Tavily tool node instance. -Exposes ``tavily_search`` as a @tool_function for real-time web search via the Tavily API. +Exposes ``tavily`` as a @tool_function for real-time web search via the Tavily API. """ from __future__ import annotations @@ -45,7 +45,7 @@ from .IGlobal import IGlobal -TAVILY_SEARCH_URL = 'https://api.tavily.com/search' +TAVILY_API_URL = 'https://api.tavily.com/search' VALID_SEARCH_DEPTHS = {'basic', 'advanced'} VALID_TOPICS = {'general', 'news', 'finance'} VALID_TIME_RANGES = {'day', 'week', 'month', 'year'} @@ -108,9 +108,9 @@ class IInstance(IInstanceBase): }, description='Search the web in real time using Tavily. Provide a natural language query to find relevant, current web pages. Returns structured results with title, URL, content snippet, and relevance score.', ) - def tavily_search(self, args): + def tavily(self, args): """Search the web using the Tavily API.""" - args = normalize_tool_input(args, tool_name='tavily_search') + args = normalize_tool_input(args, tool_name='tavily') query = (args.get('query') or '').strip() if not query: @@ -157,7 +157,7 @@ def tavily_search(self, args): } try: - body = _request_with_retry(url=TAVILY_SEARCH_URL, headers=headers, payload=payload) + body = _request_with_retry(url=TAVILY_API_URL, headers=headers, payload=payload) except RuntimeError as exc: return {'success': False, 'query': query, 'num_results': 0, 'results': [], 'error': str(exc)} @@ -250,9 +250,9 @@ def _request_with_retry( debug(f'Tavily request timeout, retrying in {delay}s ({attempt + 1}/{max_retries})') time.sleep(delay) continue - raise RuntimeError('Tavily search: request timed out after all retries') from None + raise RuntimeError('Tavily: request timed out after all retries') from None except requests.RequestException as exc: status = getattr(getattr(exc, 'response', None), 'status_code', None) detail = f' (HTTP {status})' if status else '' - raise RuntimeError(f'Tavily search request failed{detail}: {type(exc).__name__}') from None - raise RuntimeError('Tavily search: max retries exceeded') + raise RuntimeError(f'Tavily request failed{detail}: {type(exc).__name__}') from None + raise RuntimeError('Tavily: max retries exceeded') diff --git a/nodes/src/nodes/tool_tavily/README.md b/nodes/src/nodes/tool_tavily/README.md index 94ee20067..69871ae1b 100644 --- a/nodes/src/nodes/tool_tavily/README.md +++ b/nodes/src/nodes/tool_tavily/README.md @@ -1,4 +1,4 @@ -# tool_tavily_search +# tool_tavily Exposes [Tavily](https://tavily.com) real-time web search as an agent tool node. diff --git a/nodes/src/nodes/tool_tavily/services.json b/nodes/src/nodes/tool_tavily/services.json index 9a912e945..75b6dca7a 100644 --- a/nodes/src/nodes/tool_tavily/services.json +++ b/nodes/src/nodes/tool_tavily/services.json @@ -3,12 +3,12 @@ // Required: // The displayable name of this node // - "title": "Tavily Search", + "title": "Tavily", // // Required: // The protocol is the endpoint protocol // - "protocol": "tool_tavily_search://", + "protocol": "tool_tavily://", // // Required: // Class type of the node - what it does @@ -34,7 +34,7 @@ // Optional: // The path is the executable/script code. // - "path": "nodes.tool_tavily_search", + "path": "nodes.tool_tavily", // // Required: // The prefix map when converting URLs <=> paths. @@ -61,7 +61,7 @@ "default": "default", "profiles": { "default": { - "title": "Tavily Search", + "title": "Tavily", "apikey": "", "maxResults": 5, "searchDepth": "advanced", @@ -75,7 +75,7 @@ // "secure: true" fields are encrypted at rest and masked in the UI. // "fields": { - "tool_tavily_search.apikey": { + "tool_tavily.apikey": { "type": "string", "title": "API Key", "description": "Tavily API key (from https://tavily.com)", @@ -85,7 +85,7 @@ "ui:widget": "ApiKeyWidget" } }, - "tool_tavily_search.maxResults": { + "tool_tavily.maxResults": { "type": "integer", "title": "Max Results", "description": "Maximum number of search results to return (1-20)", @@ -93,7 +93,7 @@ "minimum": 1, "maximum": 20 }, - "tool_tavily_search.searchDepth": { + "tool_tavily.searchDepth": { "type": "string", "title": "Search Depth", "description": "Tavily search depth", @@ -103,7 +103,7 @@ ["advanced", "Advanced"] ] }, - "tool_tavily_search.topic": { + "tool_tavily.topic": { "type": "string", "title": "Topic", "description": "Search topic category", @@ -128,7 +128,7 @@ "outputs": [], "cases": [ { - "name": "Tavily search smoke test (requires key)", + "name": "Tavily smoke test (requires key)", "text": "test query" } ] @@ -140,8 +140,8 @@ "shape": [ { "section": "Pipe", - "title": "Tavily Search", - "properties": ["type", "tool_tavily_search.apikey", "tool_tavily_search.maxResults", "tool_tavily_search.searchDepth", "tool_tavily_search.topic"] + "title": "Tavily", + "properties": ["type", "tool_tavily.apikey", "tool_tavily.maxResults", "tool_tavily.searchDepth", "tool_tavily.topic"] } ] } diff --git a/nodes/test/test_tool_tavily.py b/nodes/test/test_tool_tavily.py index 335f0fbc0..18d226f8a 100644 --- a/nodes/test/test_tool_tavily.py +++ b/nodes/test/test_tool_tavily.py @@ -23,7 +23,7 @@ # SOFTWARE. # ============================================================================= -"""Unit tests for tool_tavily_search pure helpers (no network).""" +"""Unit tests for tool_tavily pure helpers (no network).""" from __future__ import annotations @@ -45,7 +45,7 @@ import importlib -# Add nodes/src to sys.path so `nodes.tool_tavily_search.IInstance` is resolvable. +# Add nodes/src to sys.path so `nodes.tool_tavily.IInstance` is resolvable. _NODES_SRC = Path(__file__).resolve().parents[1] / 'src' if str(_NODES_SRC) not in sys.path: sys.path.insert(0, str(_NODES_SRC)) @@ -90,7 +90,7 @@ def _build_import_stubs(): sys.modules[_name] = _stub _added_stubs.append(_name) -mod = importlib.import_module('nodes.tool_tavily_search.IInstance') +mod = importlib.import_module('nodes.tool_tavily.IInstance') # Drop the stubs we injected so they never leak into the shared pytest session. for _name in _added_stubs: From 3bd0a318f38c2ad5a3a7e246a60fc329bf126e13 Mon Sep 17 00:00:00 2001 From: EdwardLien0426 Date: Tue, 2 Jun 2026 10:17:25 -0700 Subject: [PATCH 16/16] chore: re-trigger CI (known-flaky RocketRideClient teardown on Ubuntu) No code change. The previous run failed only on Ubuntu in RocketRideClient.test.ts afterEach teardown (a pre-existing flaky integration-test disconnect race); macOS + Windows passed. This PR touches zero client-typescript files. Co-Authored-By: Claude Opus 4.8