diff --git a/nodes/src/nodes/memory_persistent/requirements.txt b/nodes/src/nodes/memory_persistent/requirements.txt index d9f7234c5..7800f0fad 100644 --- a/nodes/src/nodes/memory_persistent/requirements.txt +++ b/nodes/src/nodes/memory_persistent/requirements.txt @@ -1 +1 @@ -redis>=6.4.0,<7.0.0 +redis diff --git a/nodes/src/nodes/tool_falkordb/IGlobal.py b/nodes/src/nodes/tool_falkordb/IGlobal.py new file mode 100644 index 000000000..747b7a0e4 --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/IGlobal.py @@ -0,0 +1,112 @@ +# ============================================================================= +# 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. +# ============================================================================= + +""" +FalkorDB tool node - global (shared) state. + +Reads the connection settings from config, creates a FalkorDB client and +verifies connectivity. Tool logic lives on IInstance via @tool_function. +""" + +from __future__ import annotations + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, debug, warning + +from falkordb import FalkorDB + + +def _int_or(value, default: int, *, lo: int, hi: int) -> int: + try: + n = int(value) + except (TypeError, ValueError): + return default + return max(lo, min(n, hi)) + + +def _bool_of(value) -> bool: + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + return bool(value) + + +class IGlobal(IGlobalBase): + """Global state for tool_falkordb.""" + + client: FalkorDB | None = None + # Unprefixed config values set during beginGlobal. + graph_name: str = 'agent' + allow_writes: bool = False + max_rows: int = 250 + query_timeout_ms: int = 30000 + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + host = str((cfg.get('host') or 'localhost')).strip() or 'localhost' + port = _int_or(cfg.get('port'), 6379, lo=1, hi=65535) + username = str((cfg.get('username') or '')).strip() + # Do NOT strip the password — whitespace is valid in passwords. + password = str(cfg.get('password') or '') + tls = _bool_of(cfg.get('tls')) + + self.graph_name = str((cfg.get('graph') or 'agent')).strip() or 'agent' + self.allow_writes = _bool_of(cfg.get('allow_writes')) + self.max_rows = _int_or(cfg.get('max_rows'), 250, lo=1, hi=25000) + self.query_timeout_ms = _int_or(cfg.get('query_timeout_ms'), 30000, lo=100, hi=600000) + + client_kwargs = {'host': host, 'port': port} + if username: + client_kwargs['username'] = username + if password: + client_kwargs['password'] = password + if tls: + client_kwargs['ssl'] = True + + self.client = FalkorDB(**client_kwargs) + # Fail fast on bad host/credentials instead of on the first tool call. + self.client.list_graphs() + debug(f'tool_falkordb: connected to {host}:{port}, default graph={self.graph_name}') + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + host = str((cfg.get('host') or '')).strip() + if not host: + warning('host is required') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + if self.client is not None: + try: + self.client.close() + except Exception as e: + warning(f'tool_falkordb: close failed: {e}') + finally: + self.client = None diff --git a/nodes/src/nodes/tool_falkordb/IInstance.py b/nodes/src/nodes/tool_falkordb/IInstance.py new file mode 100644 index 000000000..6652ad2c8 --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/IInstance.py @@ -0,0 +1,265 @@ +# ============================================================================= +# 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. +# ============================================================================= + +""" +FalkorDB tool node instance. + +Exposes ``query`` (run Cypher against a graph), ``list_graphs`` and +``get_schema`` as agent tools. Queries run read-only via GRAPH.RO_QUERY +(write rejection enforced server-side) unless writes are enabled in config. +""" + +from __future__ import annotations + +import datetime + +from redis.exceptions import RedisError + +from rocketlib import IInstanceBase, tool_function + +from ai.common.utils import normalize_tool_input + +from .IGlobal import IGlobal + + +def _serialize_value(value): + """Convert FalkorDB result cells (Node/Edge/Path/temporals) to plain JSON-safe data.""" + # Graph entities are duck-typed: falkordb.Node has labels+properties, + # Edge has relation+src_node/dest_node, Path has nodes()/edges(). + if hasattr(value, 'labels') and hasattr(value, 'properties'): + return { + 'id': getattr(value, 'id', None), + 'labels': list(value.labels or []), + 'properties': _serialize_value(value.properties), + } + if hasattr(value, 'relation') and hasattr(value, 'properties'): + return { + 'id': getattr(value, 'id', None), + 'type': value.relation, + 'src': getattr(value, 'src_node', None), + 'dst': getattr(value, 'dest_node', None), + 'properties': _serialize_value(value.properties), + } + if hasattr(value, 'nodes') and hasattr(value, 'edges') and callable(getattr(value, 'nodes', None)): + return { + 'nodes': [_serialize_value(n) for n in value.nodes()], + 'edges': [_serialize_value(e) for e in value.edges()], + } + if isinstance(value, dict): + return {str(k): _serialize_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_serialize_value(v) for v in value] + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _header_names(header) -> list: + """Extract column names from a QueryResult header ([type, name] pairs or strings).""" + names = [] + for entry in header or []: + if isinstance(entry, (list, tuple)) and len(entry) >= 2: + names.append(str(entry[1])) + else: + names.append(str(entry)) + return names + + +def _write_stats(result) -> dict: + """Collect non-zero write counters from a QueryResult.""" + stats = {} + for attr in ( + 'nodes_created', + 'nodes_deleted', + 'relationships_created', + 'relationships_deleted', + 'properties_set', + 'properties_removed', + 'labels_added', + 'indices_created', + ): + try: + value = getattr(result, attr, 0) or 0 + except (RedisError, ValueError, TypeError): + value = 0 + if value: + stats[attr] = value + return stats + + +class IInstance(IInstanceBase): + IGlobal: IGlobal + + def _select_graph(self, args): + """Resolve the target graph from args or config default.""" + graph_name = args.get('graph') + if graph_name is not None and (not isinstance(graph_name, str) or not graph_name.strip()): + raise ValueError('"graph" must be a non-empty string when provided') + graph_name = (graph_name or self.IGlobal.graph_name).strip() + return self.IGlobal.client.select_graph(graph_name), graph_name + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['cypher'], + 'properties': { + 'cypher': { + 'type': 'string', + 'description': 'Cypher query to execute. Use $name placeholders with "params" for values — never inline user data into the query string.', + }, + 'params': { + 'type': 'object', + 'description': 'Parameter values referenced as $name in the query (injection-safe).', + }, + 'graph': { + 'type': 'string', + 'description': 'Graph to query; defaults to the graph configured on the node.', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'columns': {'type': 'array', 'items': {'type': 'string'}}, + 'rows': { + 'type': 'array', + 'items': {'type': 'array'}, + 'description': 'Result rows; nodes/edges are serialized to objects.', + }, + 'row_count': {'type': 'integer'}, + 'truncated': {'type': 'boolean', 'description': 'True if rows were cut at the configured cap.'}, + 'stats': { + 'type': 'object', + 'description': 'Write counters (only when writes are enabled and occurred).', + }, + 'error': {'type': 'string', 'description': 'Error message if the query failed.'}, + }, + }, + description=lambda self: ( + f'Run a Cypher query against the FalkorDB graph database ' + f'(default graph: "{self.IGlobal.graph_name}"). ' + + ( + 'Reads AND writes (CREATE/MERGE/SET/DELETE) are allowed.' + if self.IGlobal.allow_writes + else 'Read-only: write clauses (CREATE/MERGE/SET/DELETE) are rejected by the server.' + ) + + f' At most {self.IGlobal.max_rows} rows are returned.' + ), + ) + def query(self, args): + """Run Cypher against the selected graph.""" + args = normalize_tool_input(args, tool_name='falkordb') + cypher = args.get('cypher') + if not cypher or not isinstance(cypher, str) or not cypher.strip(): + raise ValueError('"cypher" is required and must be a non-empty string') + params = args.get('params') + if params is not None and not isinstance(params, dict): + raise ValueError('"params" must be an object when provided') + + try: + graph, _ = self._select_graph(args) + run = graph.query if self.IGlobal.allow_writes else graph.ro_query + result = run(cypher, params=params or None, timeout=self.IGlobal.query_timeout_ms) + except RedisError as e: + return {'error': str(e), 'columns': [], 'rows': [], 'row_count': 0, 'truncated': False} + + cap = self.IGlobal.max_rows + raw_rows = result.result_set or [] + rows = [[_serialize_value(cell) for cell in row] for row in raw_rows[:cap]] + + out = { + 'columns': _header_names(getattr(result, 'header', None)), + 'rows': rows, + 'row_count': len(rows), + 'truncated': len(raw_rows) > cap, + } + if self.IGlobal.allow_writes: + stats = _write_stats(result) + if stats: + out['stats'] = stats + return out + + @tool_function( + input_schema={'type': 'object', 'properties': {}}, + output_schema={ + 'type': 'object', + 'properties': { + 'graphs': {'type': 'array', 'items': {'type': 'string'}}, + 'error': {'type': 'string', 'description': 'Error message if the call failed.'}, + }, + }, + description='List the graph names that exist in this FalkorDB instance.', + ) + def list_graphs(self, args): + """List graphs on the server.""" + try: + graphs = self.IGlobal.client.list_graphs() + except RedisError as e: + return {'error': str(e), 'graphs': []} + return {'graphs': [str(g) for g in graphs or []]} + + @tool_function( + input_schema={ + 'type': 'object', + 'properties': { + 'graph': { + 'type': 'string', + 'description': 'Graph to inspect; defaults to the graph configured on the node.', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'labels': {'type': 'array', 'items': {'type': 'string'}}, + 'relationship_types': {'type': 'array', 'items': {'type': 'string'}}, + 'property_keys': {'type': 'array', 'items': {'type': 'string'}}, + 'error': {'type': 'string', 'description': 'Error message if the call failed.'}, + }, + }, + description=( + 'Return the graph schema: node labels, relationship types and property keys. ' + 'Use when a query returns unexpected results or you need to discover the data model.' + ), + ) + def get_schema(self, args): + """Return labels, relationship types and property keys of a graph.""" + args = normalize_tool_input(args, tool_name='falkordb') + + def _column(graph, procedure: str) -> list: + result = graph.ro_query(f'CALL {procedure}()', timeout=self.IGlobal.query_timeout_ms) + return [str(row[0]) for row in (result.result_set or [])] + + try: + graph, _ = self._select_graph(args) + return { + 'labels': _column(graph, 'db.labels'), + 'relationship_types': _column(graph, 'db.relationshipTypes'), + 'property_keys': _column(graph, 'db.propertyKeys'), + } + except RedisError as e: + return {'error': str(e), 'labels': [], 'relationship_types': [], 'property_keys': []} diff --git a/nodes/src/nodes/tool_falkordb/README.md b/nodes/src/nodes/tool_falkordb/README.md new file mode 100644 index 000000000..aa6ed5816 --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/README.md @@ -0,0 +1,64 @@ +--- +title: FalkorDB +date: 2026-06-11 +sidebar_position: 1 +--- + +## What it does + +Lets agents query a [FalkorDB](https://www.falkordb.com) graph database with Cypher. Queries are **read-only by default** — they run via `GRAPH.RO_QUERY`, so the server itself rejects write clauses; flip *Allow Writes* to let the agent mutate the graph. Values always go through Cypher parameters (`$name`), which FalkorDB treats as data, never as query text. + +## Tools + +| Tool | Description | +| --------------------- | -------------------------------------------------------- | +| `falkordb.query` | Run a Cypher query and return rows | +| `falkordb.list_graphs`| List graph names on the server | +| `falkordb.get_schema` | Node labels, relationship types and property keys | + +### falkordb.query + +| Parameter | Required | Description | +| --------- | -------- | -------------------------------------------------------- | +| `cypher` | yes | Cypher query; reference values as `$name` | +| `params` | no | Values for the `$name` placeholders (injection-safe) | +| `graph` | no | Graph to query (default from config) | + +Returns `columns`, `rows` (nodes/edges serialized to objects, capped at *Max Rows* with a `truncated` flag) and, when writes are enabled, non-zero write `stats`. On a query failure all three tools return `error` instead (with empty `rows`/`columns`, `row_count: 0`, `truncated: false` for `query`). + +### falkordb.list_graphs + +No parameters. Returns `graphs`. + +### falkordb.get_schema + +| Parameter | Required | Description | +| --------- | -------- | ------------------------------------ | +| `graph` | no | Graph to inspect (default from config) | + +Returns `labels`, `relationship_types` and `property_keys`. + +## Configuration + +| Field | Description | +| ----------------- | ---------------------------------------------------------------------- | +| Host / Port | FalkorDB endpoint (local Docker, self-hosted, or FalkorDB Cloud) | +| Username / Password | Credentials; FalkorDB Cloud uses `default` + instance password | +| TLS | Enable for TLS endpoints | +| Default Graph | Graph used when the agent does not pass one | +| Allow Writes | OFF = `GRAPH.RO_QUERY` (server rejects writes); ON = full read/write | +| Max Rows | Row cap per query returned to the agent | +| Query Timeout (ms)| Server-side per-query timeout | + +## Local quickstart + +```bash +docker run -p 6379:6379 -it --rm falkordb/falkordb:latest +``` + +Point the node at `localhost:6379` and ask the agent to `CREATE` (with *Allow Writes* on) or `MATCH` away. + +## Upstream docs + +- [FalkorDB documentation](https://docs.falkordb.com) +- [Cypher coverage](https://docs.falkordb.com/cypher/) diff --git a/nodes/src/nodes/tool_falkordb/__init__.py b/nodes/src/nodes/tool_falkordb/__init__.py new file mode 100644 index 000000000..d678f5ae5 --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/__init__.py @@ -0,0 +1,35 @@ +# ============================================================================= +# 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 os.path import dirname, join, realpath +from depends import depends + +requirements = join(dirname(realpath(__file__)), 'requirements.txt') +depends(requirements) + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_falkordb/doc.md b/nodes/src/nodes/tool_falkordb/doc.md new file mode 100644 index 000000000..a324c580f --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/doc.md @@ -0,0 +1,88 @@ +--- +title: FalkorDB +date: 2026-06-11 +sidebar_position: 1 +--- + +## What it does + +Lets agents query a [FalkorDB](https://www.falkordb.com) graph database with Cypher. Queries are **read-only by default** — they run via `GRAPH.RO_QUERY`, so the server itself rejects write clauses; flip *Allow Writes* to let the agent mutate the graph. Values always go through Cypher parameters (`$name`), which FalkorDB treats as data, never as query text. + +## Tools + +| Tool | Description | +| --------------------- | -------------------------------------------------------- | +| `falkordb.query` | Run a Cypher query and return rows | +| `falkordb.list_graphs`| List graph names on the server | +| `falkordb.get_schema` | Node labels, relationship types and property keys | + +### falkordb.query + +| Parameter | Required | Description | +| --------- | -------- | -------------------------------------------------------- | +| `cypher` | yes | Cypher query; reference values as `$name` | +| `params` | no | Values for the `$name` placeholders (injection-safe) | +| `graph` | no | Graph to query (default from config) | + +Returns `columns`, `rows` (nodes/edges serialized to objects, capped at *Max Rows* with a `truncated` flag) and, when writes are enabled, non-zero write `stats`. On a query failure all three tools return `error` instead (with empty `rows`/`columns`, `row_count: 0`, `truncated: false` for `query`). + +### falkordb.list_graphs + +No parameters. Returns `graphs`. + +### falkordb.get_schema + +| Parameter | Required | Description | +| --------- | -------- | ------------------------------------ | +| `graph` | no | Graph to inspect (default from config) | + +Returns `labels`, `relationship_types` and `property_keys`. + +## Configuration + +| Field | Description | +| ----------------- | ---------------------------------------------------------------------- | +| Host / Port | FalkorDB endpoint (local Docker, self-hosted, or FalkorDB Cloud) | +| Username / Password | Credentials; FalkorDB Cloud uses `default` + instance password | +| TLS | Enable for TLS endpoints | +| Default Graph | Graph used when the agent does not pass one | +| Allow Writes | OFF = `GRAPH.RO_QUERY` (server rejects writes); ON = full read/write | +| Max Rows | Row cap per query returned to the agent | +| Query Timeout (ms)| Server-side per-query timeout | + +## Local quickstart + +```bash +docker run -p 6379:6379 -it --rm falkordb/falkordb:latest +``` + +Point the node at `localhost:6379` and ask the agent to `CREATE` (with *Allow Writes* on) or `MATCH` away. + +## Upstream docs + +- [FalkorDB documentation](https://docs.falkordb.com) +- [Cypher coverage](https://docs.falkordb.com/cypher/) + +## Reference + + + + +| Property | Value | +| --- | --- | +| Class type | tool | +| Capabilities | invoke | +| Protocol | `tool_falkordb://` | + +**Profiles** + +| Profile | Title | Model | +| --- | --- | --- | +| `default` | FalkorDB | | + +**Configuration sections** + +| Section | Fields | +| --- | --- | +| FalkorDB | `type`, `tool_falkordb.host`, `tool_falkordb.port`, `tool_falkordb.username`, `tool_falkordb.password`, `tool_falkordb.tls`, `tool_falkordb.graph`, `tool_falkordb.allow_writes`, `tool_falkordb.max_rows`, `tool_falkordb.query_timeout_ms` | + diff --git a/nodes/src/nodes/tool_falkordb/falkordb.svg b/nodes/src/nodes/tool_falkordb/falkordb.svg new file mode 100644 index 000000000..f6ee0c17b --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/falkordb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/nodes/src/nodes/tool_falkordb/requirements.txt b/nodes/src/nodes/tool_falkordb/requirements.txt new file mode 100644 index 000000000..adf28423a --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/requirements.txt @@ -0,0 +1,4 @@ +falkordb>=1.6,<2 +# Declared because IInstance imports redis.exceptions directly: falkordb-py +# has no error hierarchy of its own and re-raises redis-py exceptions verbatim. +redis>=7.1,<8 diff --git a/nodes/src/nodes/tool_falkordb/services.json b/nodes/src/nodes/tool_falkordb/services.json new file mode 100644 index 000000000..13850efae --- /dev/null +++ b/nodes/src/nodes/tool_falkordb/services.json @@ -0,0 +1,108 @@ +{ + "title": "FalkorDB", + "protocol": "tool_falkordb://", + "classType": ["tool"], + "capabilities": ["invoke", "experimental"], + "register": "filter", + "node": "python", + "path": "nodes.tool_falkordb", + "prefix": "falkordb", + "icon": "falkordb.svg", + "documentation": "https://docs.rocketride.org", + "description": ["Lets agents query a FalkorDB graph database with Cypher.", "Provides query (read-only by default, server-enforced), list_graphs and get_schema."], + "tile": [], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "FalkorDB" + } + } + }, + "fields": { + "tool_falkordb.host": { + "type": "string", + "title": "Host", + "description": "FalkorDB host, e.g. localhost or your-instance.falkordb.cloud.", + "default": "localhost" + }, + "tool_falkordb.port": { + "type": "integer", + "title": "Port", + "description": "FalkorDB port (Redis protocol).", + "default": 6379, + "minimum": 1, + "maximum": 65535 + }, + "tool_falkordb.username": { + "type": "string", + "title": "Username", + "description": "Username, e.g. \"default\" for FalkorDB Cloud. Leave empty for no auth.", + "default": "", + "optional": true + }, + "tool_falkordb.password": { + "type": "string", + "title": "Password", + "description": "Password for the FalkorDB instance. Leave empty for no auth.", + "default": "", + "secure": true, + "optional": true, + "ui": { + "ui:widget": "password" + } + }, + "tool_falkordb.tls": { + "type": "boolean", + "title": "TLS", + "description": "Connect with TLS (FalkorDB Cloud TLS endpoints).", + "default": false + }, + "tool_falkordb.graph": { + "type": "string", + "title": "Default Graph", + "description": "Graph queried when the agent does not pass one explicitly.", + "default": "agent" + }, + "tool_falkordb.allow_writes": { + "type": "boolean", + "title": "Allow Writes", + "description": "Permit CREATE/MERGE/SET/DELETE. When off, queries run via GRAPH.RO_QUERY and the server rejects write clauses.", + "default": false + }, + "tool_falkordb.max_rows": { + "type": "integer", + "title": "Max Rows", + "description": "Upper cap on rows returned to the agent per query.", + "default": 250, + "minimum": 1, + "maximum": 25000 + }, + "tool_falkordb.query_timeout_ms": { + "type": "integer", + "title": "Query Timeout (ms)", + "description": "Server-side timeout for a single query.", + "default": 30000, + "minimum": 100, + "maximum": 600000 + } + }, + "test": { + "profiles": ["default"], + "outputs": [], + "cases": [ + { + "name": "FalkorDB smoke test (mock client)", + "text": "test query" + } + ] + }, + "shape": [ + { + "section": "Pipe", + "title": "FalkorDB", + "properties": ["type", "tool_falkordb.host", "tool_falkordb.port", "tool_falkordb.username", "tool_falkordb.password", "tool_falkordb.tls", "tool_falkordb.graph", "tool_falkordb.allow_writes", "tool_falkordb.max_rows", "tool_falkordb.query_timeout_ms"] + } + ] +} diff --git a/nodes/test/mocks/falkordb/__init__.py b/nodes/test/mocks/falkordb/__init__.py new file mode 100644 index 000000000..cd179d089 --- /dev/null +++ b/nodes/test/mocks/falkordb/__init__.py @@ -0,0 +1,71 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Mock FalkorDB client for node testing — no real server is contacted.""" + +from __future__ import annotations + + +class _MockQueryResult: + def __init__(self, result_set=None, header=None): + self.result_set = result_set or [] + self.header = header or [] + self.nodes_created = 0 + self.nodes_deleted = 0 + self.relationships_created = 0 + self.relationships_deleted = 0 + self.properties_set = 0 + self.properties_removed = 0 + self.labels_added = 0 + self.indices_created = 0 + + +class _MockGraph: + def __init__(self, name): + self.name = name + + def query(self, q, params=None, timeout=None, **kwargs): + return _MockQueryResult(result_set=[['mock']], header=[[1, 'value']]) + + def ro_query(self, q, params=None, timeout=None, **kwargs): + return _MockQueryResult(result_set=[['mock']], header=[[1, 'value']]) + + +class FalkorDB: + def __init__(self, host='localhost', port=6379, username=None, password=None, ssl=False, **kwargs): + self.host = host + self.port = port + + def select_graph(self, name): + return _MockGraph(name) + + def list_graphs(self): + return ['mock-graph'] + + def close(self): + pass + + +__all__ = ['FalkorDB'] diff --git a/nodes/test/test_tool_falkordb.py b/nodes/test/test_tool_falkordb.py new file mode 100644 index 000000000..4f39602b5 --- /dev/null +++ b/nodes/test/test_tool_falkordb.py @@ -0,0 +1,284 @@ +# ============================================================================= +# 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_falkordb helpers and tool-method behavior (no server).""" + +from __future__ import annotations + +import datetime +import importlib +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Bootstrap: when run under a bare interpreter that lacks the engine runtime +# (rocketlib, ai.common, falkordb, redis), inject lightweight stubs ONLY for +# modules that are not already present, import the module under test, then +# REMOVE the stubs we added so they never leak into the shared pytest session +# (see test_tool_tavily.py for the full rationale). +# --------------------------------------------------------------------------- + +_NODES_SRC = Path(__file__).resolve().parents[1] / 'src' +if str(_NODES_SRC) not in sys.path: + sys.path.insert(0, str(_NODES_SRC)) + + +class _StubRedisError(Exception): + """Real exception class so IInstance's except clauses catch it under the stub.""" + + +def _build_import_stubs(): + """Return {module_name: stub} for the deps needed only to import the module.""" + rocketlib = MagicMock() + rocketlib.IInstanceBase = object + rocketlib.IGlobalBase = object + rocketlib.tool_function = lambda **kwargs: lambda f: f + 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 {} + + falkordb = MagicMock() + falkordb.FalkorDB = MagicMock() + + redis_exceptions = MagicMock() + redis_exceptions.RedisError = _StubRedisError + redis = MagicMock() + redis.exceptions = redis_exceptions + + return { + 'rocketlib': rocketlib, + 'depends': depends, + 'ai': MagicMock(), + 'ai.common': MagicMock(), + 'ai.common.utils': ai_common_utils, + 'ai.common.config': MagicMock(), + 'falkordb': falkordb, + 'redis': redis, + 'redis.exceptions': redis_exceptions, + } + + +_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_falkordb.IInstance') + +for _name in _added_stubs: + sys.modules.pop(_name, None) + + +class _FakeNode: + def __init__(self, node_id=1, labels=None, properties=None): + self.id = node_id + self.labels = labels or ['Person'] + self.properties = properties or {'name': 'Alice'} + + +class _FakeEdge: + def __init__(self): + self.id = 7 + self.relation = 'KNOWS' + self.src_node = 1 + self.dest_node = 2 + self.properties = {'since': 2020} + + +class _FakeResult: + def __init__(self, result_set=None, header=None, **stats): + self.result_set = result_set or [] + self.header = header or [] + for attr in ( + 'nodes_created', + 'nodes_deleted', + 'relationships_created', + 'relationships_deleted', + 'properties_set', + 'properties_removed', + 'labels_added', + 'indices_created', + ): + setattr(self, attr, stats.get(attr, 0)) + + +class _FakeGraph: + def __init__(self, result=None, raise_error=None): + self._result = result or _FakeResult() + self._raise = raise_error + self.calls = [] + + def query(self, q, params=None, timeout=None): + self.calls.append(('query', q, params, timeout)) + if self._raise: + raise self._raise + return self._result + + def ro_query(self, q, params=None, timeout=None): + self.calls.append(('ro_query', q, params, timeout)) + if self._raise: + raise self._raise + return self._result + + +class _FakeClient: + def __init__(self, graph): + self._graph = graph + self.selected = [] + + def select_graph(self, name): + self.selected.append(name) + return self._graph + + def list_graphs(self): + return ['g1', 'g2'] + + +class _FakeGlobal: + def __init__(self, graph, *, allow_writes=False, max_rows=250, graph_name='agent'): + self.client = _FakeClient(graph) + self.allow_writes = allow_writes + self.max_rows = max_rows + self.graph_name = graph_name + self.query_timeout_ms = 30000 + + +def _instance(global_state): + inst = mod.IInstance() + inst.IGlobal = global_state + return inst + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def test_serialize_node_edge_and_temporal(): + node = mod._serialize_value(_FakeNode()) + assert node == {'id': 1, 'labels': ['Person'], 'properties': {'name': 'Alice'}} + + edge = mod._serialize_value(_FakeEdge()) + assert edge == {'id': 7, 'type': 'KNOWS', 'src': 1, 'dst': 2, 'properties': {'since': 2020}} + + stamp = datetime.datetime(2026, 6, 11, 12, 0, 0) + assert mod._serialize_value(stamp) == '2026-06-11T12:00:00' + assert mod._serialize_value([1, 'a', None]) == [1, 'a', None] + + +def test_header_names_handles_pairs_and_strings(): + assert mod._header_names([[1, 'name'], [2, 'age']]) == ['name', 'age'] + assert mod._header_names(['plain']) == ['plain'] + assert mod._header_names(None) == [] + + +# --------------------------------------------------------------------------- +# query: routing, caps, errors +# --------------------------------------------------------------------------- + + +def test_query_uses_ro_query_when_writes_disabled(): + graph = _FakeGraph(_FakeResult(result_set=[['x']], header=[[1, 'value']])) + inst = _instance(_FakeGlobal(graph, allow_writes=False)) + out = inst.query({'cypher': 'MATCH (n) RETURN n'}) + assert graph.calls[0][0] == 'ro_query' + assert out['columns'] == ['value'] + assert out['row_count'] == 1 + + +def test_query_uses_query_when_writes_enabled_and_reports_stats(): + graph = _FakeGraph(_FakeResult(result_set=[], header=[], nodes_created=2)) + inst = _instance(_FakeGlobal(graph, allow_writes=True)) + out = inst.query({'cypher': 'CREATE (n) RETURN n'}) + assert graph.calls[0][0] == 'query' + assert out['stats'] == {'nodes_created': 2} + + +def test_query_caps_rows_and_flags_truncation(): + rows = [[i] for i in range(10)] + graph = _FakeGraph(_FakeResult(result_set=rows, header=[[1, 'n']])) + inst = _instance(_FakeGlobal(graph, max_rows=3)) + out = inst.query({'cypher': 'MATCH (n) RETURN n'}) + assert out['row_count'] == 3 + assert out['truncated'] is True + + +def test_query_rejects_bad_params_without_touching_client(): + graph = _FakeGraph() + glb = _FakeGlobal(graph) + inst = _instance(glb) + with pytest.raises(ValueError): + inst.query({'cypher': 'MATCH (n) RETURN n', 'params': 'not-a-dict'}) + assert graph.calls == [] + + +def test_query_returns_error_dict_on_redis_error(): + # Raise the class the module actually bound — under `builder nodes:test-full` + # the real redis may already be imported, and the file-local stub would + # then not be caught by IInstance's `except RedisError`. + graph = _FakeGraph(raise_error=mod.RedisError('bad cypher')) + inst = _instance(_FakeGlobal(graph)) + out = inst.query({'cypher': 'MATCH (n) RETURN n'}) + assert out['error'] == 'bad cypher' + assert out['rows'] == [] + + +def test_query_graph_override_and_default(): + graph = _FakeGraph(_FakeResult()) + glb = _FakeGlobal(graph, graph_name='default-graph') + inst = _instance(glb) + inst.query({'cypher': 'MATCH (n) RETURN n'}) + inst.query({'cypher': 'MATCH (n) RETURN n', 'graph': 'other'}) + assert glb.client.selected == ['default-graph', 'other'] + + +# --------------------------------------------------------------------------- +# list_graphs / get_schema +# --------------------------------------------------------------------------- + + +def test_list_graphs_returns_names(): + inst = _instance(_FakeGlobal(_FakeGraph())) + assert inst.list_graphs({}) == {'graphs': ['g1', 'g2']} + + +def test_get_schema_shapes_columns(): + graph = _FakeGraph(_FakeResult(result_set=[['Person'], ['City']], header=[[1, 'label']])) + inst = _instance(_FakeGlobal(graph)) + out = inst.get_schema({}) + assert out['labels'] == ['Person', 'City'] + # All three procedures run read-only. + assert all(call[0] == 'ro_query' for call in graph.calls)