-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(nodes): add tool_gohighlevel CRM tool node #1695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
charliegillet
wants to merge
3
commits into
develop
Choose a base branch
from
feat/RR-1676-tool-gohighlevel
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| # ============================================================================= | ||
| # 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. | ||
| # ============================================================================= | ||
|
|
||
| """ | ||
| GoHighLevel tool node - global (shared) state. | ||
|
|
||
| Reads the sub-account Private Integration Token, the location id it is scoped to, | ||
| the read-only flag, the published tool groups, and the raw-request switch from config. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from ai.common.config import Config | ||
| from rocketlib import IGlobalBase, OPEN_MODE, warning | ||
|
|
||
| from .tool_groups import DEFAULT_GROUPS, normalize_groups, unknown_groups | ||
|
|
||
| #: Every Private Integration Token observed so far carries this prefix. It is not a | ||
| #: documented guarantee, so a token without it is warned about and still used. | ||
| TOKEN_PREFIX = 'pit-' | ||
|
|
||
|
|
||
| class IGlobal(IGlobalBase): | ||
| """Global state for tool_gohighlevel.""" | ||
|
|
||
| token: str = '' | ||
| location_id: str = '' | ||
| read_only: bool = False | ||
| tool_groups: frozenset = DEFAULT_GROUPS | ||
| allow_raw_request: bool = True | ||
|
|
||
| def beginGlobal(self) -> None: | ||
| """Read the node config and fail early when the node cannot reach an account. | ||
|
|
||
| Raises: | ||
| Exception: If the token or the location id is missing. Both are required: | ||
| the token is opaque, so the location cannot be derived from it, and | ||
| without a location id GoHighLevel answers 403 with a message that | ||
| blames the token rather than the missing parameter. | ||
| """ | ||
| if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: | ||
| return | ||
|
|
||
| from depends import load_depends | ||
|
|
||
| load_depends(__file__) | ||
|
|
||
| cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) | ||
| self.token = str((cfg.get('privateIntegrationToken') or '')).strip() | ||
| self.location_id = str((cfg.get('locationId') or '')).strip() | ||
| self.read_only = bool(cfg.get('readOnly', False)) | ||
| self.tool_groups = normalize_groups(cfg.get('toolGroups')) | ||
| self.allow_raw_request = bool(cfg.get('allowRawRequest', True)) | ||
|
|
||
| if not self.token: | ||
| raise Exception('tool_gohighlevel: privateIntegrationToken is required') | ||
| if not self.location_id: | ||
| raise Exception('tool_gohighlevel: locationId is required (the sub-account this token is scoped to)') | ||
|
|
||
| def validateConfig(self) -> None: | ||
| """Surface config problems in the editor without starting the backend.""" | ||
| try: | ||
| cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) | ||
| token = str((cfg.get('privateIntegrationToken') or '')).strip() | ||
| if not token: | ||
| warning('privateIntegrationToken is required') | ||
| elif not token.startswith(TOKEN_PREFIX): | ||
| warning( | ||
| 'privateIntegrationToken does not start with "pit-": agency tokens, OAuth access tokens ' | ||
| 'and v1 API keys are not accepted by this node' | ||
| ) | ||
| if not str((cfg.get('locationId') or '')).strip(): | ||
| warning('locationId is required (the sub-account this Private Integration Token is scoped to)') | ||
| unknown = unknown_groups(cfg.get('toolGroups')) | ||
| if unknown: | ||
| warning(f'unknown tool group(s): {", ".join(unknown)}') | ||
| except Exception as e: | ||
| warning(str(e)) | ||
|
|
||
| def endGlobal(self) -> None: | ||
| """Release shared state, wiping the credential.""" | ||
| self.token = '' | ||
| self.location_id = '' | ||
| self.read_only = False | ||
| self.tool_groups = DEFAULT_GROUPS | ||
| self.allow_raw_request = True |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| # ============================================================================= | ||
| # 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. | ||
| # ============================================================================= | ||
|
|
||
| """ | ||
| GoHighLevel tool node instance. | ||
|
|
||
| Composes one mixin per GoHighLevel resource group, publishes only the tools the | ||
| operator enabled, and adds the generic ``request`` escape hatch that reaches any | ||
| v2 endpoint the typed tools do not model. | ||
|
|
||
| Publication is filtered twice here, in one pass. The group stamp decides which | ||
| resource areas an agent sees at all, and the write stamp removes the tools a | ||
| read-only node could never run. Both are stamped by ``@gohighlevel_tool``, so | ||
| there is no registry to keep in sync with the tool definitions. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Callable | ||
|
|
||
| from rocketlib import IInstanceBase, tool_function | ||
|
|
||
| from ai.common.utils import require_str | ||
|
|
||
| from .gohighlevel_client import redact_payload | ||
| from .IGlobal import IGlobal | ||
| from .tool_groups import RAW_REQUEST_TOOL | ||
| from .tools import ( | ||
| AppointmentNotesMixin, | ||
| AppointmentsMixin, | ||
| BusinessesMixin, | ||
| CalendarGroupsMixin, | ||
| CalendarsMixin, | ||
| ContactNotesMixin, | ||
| ContactTasksMixin, | ||
| ContactsMixin, | ||
| ConversationsMixin, | ||
| CustomFieldsMixin, | ||
| CustomValuesMixin, | ||
| LocationTagsMixin, | ||
| LocationsMixin, | ||
| MessagesMixin, | ||
| OpportunitiesMixin, | ||
| PipelinesMixin, | ||
| UsersMixin, | ||
| ) | ||
|
|
||
| #: Methods the ``request`` tool accepts without write permission. | ||
| #: | ||
| #: GET only, deliberately narrower than the conventional safe set. The read-only config | ||
| #: field tells the operator that the request tool accepts GET in that mode, and HEAD is | ||
| #: not documented on a single GoHighLevel operation, so there is nothing to gain by | ||
| #: quietly widening the promise. | ||
| _SAFE_METHODS = {'GET'} | ||
| _ALLOWED_METHODS = {'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'} | ||
|
|
||
|
|
||
| class IInstance( | ||
| AppointmentNotesMixin, | ||
| AppointmentsMixin, | ||
| BusinessesMixin, | ||
| CalendarGroupsMixin, | ||
| CalendarsMixin, | ||
| ContactNotesMixin, | ||
| ContactTasksMixin, | ||
| ContactsMixin, | ||
| ConversationsMixin, | ||
| CustomFieldsMixin, | ||
| CustomValuesMixin, | ||
| LocationTagsMixin, | ||
| LocationsMixin, | ||
| MessagesMixin, | ||
| OpportunitiesMixin, | ||
| PipelinesMixin, | ||
| UsersMixin, | ||
| IInstanceBase, | ||
| ): | ||
| IGlobal: IGlobal | ||
|
|
||
| # ----------------------------------------------------------------------- | ||
| # Tool publication | ||
| # ----------------------------------------------------------------------- | ||
|
|
||
| def _collect_tool_methods(self) -> dict[str, Callable]: | ||
| """Publish only the tools this node's configuration allows an agent to run. | ||
|
|
||
| The base implementation returns every ``@tool_function`` on the class, which for | ||
| full GoHighLevel coverage is far more than an agent can choose between. Two filters | ||
| run here, and both cover ``tool.query`` (the agent never sees the tool) as well as | ||
| ``tool.invoke`` (calling it anyway is refused): | ||
|
|
||
| 1. The resource group, against ``gohighlevel.toolGroups``. | ||
| 2. The write stamp, when ``gohighlevel.readOnly`` is on. This is a deliberate | ||
| deviation from tool_pipedrive, which publishes its write tools in read-only mode | ||
| and refuses them at invoke time. An agent cannot tell in advance that a published | ||
| tool is blocked, so it spends a turn finding out, and roughly 40 tools it can only | ||
| ever fail on are 40 tools' worth of wasted context. | ||
| ``GoHighLevelToolsBase._require_write`` still guards invoke, both as defence in | ||
| depth and because the raw ``request`` tool carries no stamp at all. | ||
| """ | ||
| methods = super()._collect_tool_methods() | ||
| enabled = self.IGlobal.tool_groups | ||
| allow_raw = self.IGlobal.allow_raw_request | ||
| read_only = self.IGlobal.read_only | ||
|
|
||
| published: dict[str, Callable] = {} | ||
| for name, method in methods.items(): | ||
| if name == RAW_REQUEST_TOOL: | ||
| # Gated by its own config switch rather than by a group. It stays published | ||
| # in read-only mode because it is still a working read tool there. | ||
| if allow_raw: | ||
| published[name] = method | ||
| continue | ||
| declared = getattr(type(self), name, None) | ||
| group = getattr(declared, '__gohighlevel_group__', None) | ||
| if group is not None and group not in enabled: | ||
| continue | ||
| if read_only and getattr(declared, '__gohighlevel_writes__', False): | ||
| continue | ||
| published[name] = method | ||
| return published | ||
|
|
||
| # ----------------------------------------------------------------------- | ||
| # Escape hatch | ||
| # ----------------------------------------------------------------------- | ||
|
|
||
| @tool_function( | ||
| input_schema={ | ||
| 'type': 'object', | ||
| 'required': ['method', 'path'], | ||
| 'properties': { | ||
| 'method': { | ||
| 'type': 'string', | ||
| 'enum': sorted(_ALLOWED_METHODS), | ||
| 'description': 'HTTP method. In read-only mode only GET is permitted.', | ||
| }, | ||
| 'path': { | ||
| 'type': 'string', | ||
| 'description': ( | ||
| 'Path relative to the API root, starting with a slash: for example "/contacts/", ' | ||
| '"/opportunities/pipelines" or "/locations/<locationId>/customValues". Do not include ' | ||
| 'the host. A trailing slash is load-bearing on several endpoints, and so is casing: ' | ||
| 'GoHighLevel answers a mis-cased path with 401 rather than 404, so copy the path from ' | ||
| 'the API reference exactly.' | ||
| ), | ||
| }, | ||
| 'params': { | ||
| 'type': 'object', | ||
| 'description': ( | ||
| 'Query-string parameters. The sub-account id is not added for you here: most endpoints ' | ||
| 'want it as locationId, and GET /opportunities/search spells it location_id.' | ||
| ), | ||
| }, | ||
| 'body': { | ||
| 'type': 'object', | ||
| 'description': ( | ||
| 'JSON request body for POST, PUT and PATCH, and for the few DELETE endpoints that require one.' | ||
| ), | ||
| }, | ||
| 'version': { | ||
| 'type': 'string', | ||
| 'description': ( | ||
| 'Value for the Version header, which every GoHighLevel operation requires. It defaults ' | ||
| 'to the value derived from the path, which is 2021-04-15 for /calendars and ' | ||
| '/conversations and 2021-07-28 for everything else. Set it only when the endpoint you ' | ||
| 'are calling documents a different value.' | ||
| ), | ||
| }, | ||
| }, | ||
| }, | ||
| description=( | ||
| 'Call any GoHighLevel v2 endpoint at https://services.leadconnectorhq.com directly, by method and ' | ||
| 'path. Use this only for endpoints that have no dedicated tool here: the typed tools validate their ' | ||
| 'input, put the sub-account id wherever the endpoint wants it and return a compact result, while ' | ||
| 'this one sends what you give it and returns the raw response body. The Authorization and Version ' | ||
| 'headers are added automatically, so do not pass them. Rate-limit retries and read-only enforcement ' | ||
| 'apply here too.' | ||
| ), | ||
| ) | ||
| def request(self, args): | ||
| """Call any GoHighLevel v2 endpoint directly.""" | ||
| args = self._args(args, 'request') | ||
| method = require_str(args, 'method', tool_name='request').upper() | ||
| path = require_str(args, 'path', tool_name='request') | ||
|
|
||
| if method not in _ALLOWED_METHODS: | ||
| raise ValueError(f'request: method must be one of {", ".join(sorted(_ALLOWED_METHODS))}') | ||
| if method not in _SAFE_METHODS: | ||
| self._require_write() | ||
|
|
||
| if '://' in path: | ||
| raise ValueError('request: "path" must be a path such as "/contacts/", not a full URL') | ||
|
|
||
| params = args.get('params') | ||
| body = args.get('body') | ||
| version = args.get('version') | ||
| if params is not None and not isinstance(params, dict): | ||
| raise ValueError('request: "params" must be an object') | ||
| if body is not None and not isinstance(body, dict): | ||
| raise ValueError('request: "body" must be an object') | ||
| if version is not None and not isinstance(version, str): | ||
| raise ValueError('request: "version" must be a string such as "2021-07-28"') | ||
|
|
||
| payload = self._call_envelope(method, path, params=params, body=body, version=version or None) | ||
| # The only tool that returns a response this node has not projected through a key | ||
| # allowlist, so it is the one place a payload could echo the credential back. | ||
| return redact_payload(payload, self._token()) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You flagged this yourself in the description, so: I would add a permissive one rather than leave the exemption standing.
test_every_published_tool_has_an_output_schemacurrently has to special-caserequest, which means the invariant is enforced for 101 of 102 published tools and the 102nd is enforced by nobody. Something likecosts nothing, tells the agent the one thing that actually distinguishes this tool from the other 101 (that the result is not trimmed), and lets the test cover 102/102 with no carve-out.