Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]: since 2026-06-08

### Added

- **`tool_pipedrive` node**: exposes the Pipedrive CRM REST API v1 to agents — deals, persons, organizations, activities, pipelines, stages, notes, leads, products, fields, files, users, roles, permission sets, teams, goals, filters, webhooks, subscriptions, mail threads, call logs and projects. 256 tools across 24 resource groups, plus a generic `request` tool that reaches any remaining v1 endpoint through the same auth, rate-limit and read-only layer. Because the full surface is larger than an LLM can choose between reliably, `pipedrive.toolGroups` controls which groups are published (default: the eight core CRM groups, 108 tools; `all` publishes everything). Supports personal API tokens and OAuth bearer tokens, company-domain base URLs, offset and cursor pagination, custom fields via an `extra` passthrough, and read-only mode (#1675)

## [3.3.0] - 2026-06-08

### ⚠ Breaking Changes: Client SDKs (`rocketride` / `rocketride-python`)
Expand Down
3 changes: 2 additions & 1 deletion docs/README-nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ each expose multiple variants), which is why the catalog below lists **services*
rather than directories.

> This catalog is compiled by hand from the `services*.json` definitions on `develop`
> (88 node directories → 118 services). For node testing, see
> (118 node directories → 148 services). For node testing, see
> [README-node-testing.md](README-node-testing.md).

---
Expand Down Expand Up @@ -109,6 +109,7 @@ channel; they have no data lanes and **bind to an agent** (see
| `tool_firecrawl` | Firecrawl web-scraping operations |
| `tool_http_request` | Arbitrary HTTP requests, "curl for agents" |
| `tool_github` | GitHub repository operations |
| `tool_pipedrive` | Pipedrive CRM operations: deals, persons, organizations, activities, and the rest of API v1 |
| `tool_git` | Local Git repository operations |
| `tool_filesystem` | File-system access |
| `tool_python` | Executes Python in a restricted in-process sandbox |
Expand Down
6 changes: 4 additions & 2 deletions nodes/src/nodes/agent_crewai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Run CrewAI agents inside RocketRide: as a standalone agent, as a hierarchical ma
Three node variants share the same base driver (`crewai_base.py`), each loaded from its own sub-package:

- **CrewAI Agent** (`agent_crewai`): a standalone single agent. Assembles a CrewAI `Agent` + `Task` into a one-agent, sequential-process `Crew` and runs it to answer the incoming question.
- **CrewAI Manager** (`agent_crewai_manager`): orchestrates a crew using CrewAI's hierarchical process. Fans out a `describe` invoke to connected CrewAI Subagent nodes, assembles a `Crew` with the manager as delegator (`planning=True`, using the manager's LLM as the planning LLM), and synthesizes sub-agent outputs into one answer.
- **CrewAI Manager** (`agent_crewai_manager`): orchestrates a crew using CrewAI's hierarchical process. Fans out a `describe` invoke to connected CrewAI Subagent nodes, assembles a `Crew` with the manager as delegator, and synthesizes sub-agent outputs into one answer. CrewAI's planner is opt-in via the **Crew Planning** field and off by default.
- **CrewAI Subagent** (`agent_crewai_subagent`): a managed worker. Wired into a Manager via the `crewai` channel and delegated to by name (its `role`); it has no `questions` lane and cannot be invoked directly.

Uses the **crewai** library (`>=1.14.1`). The node never calls a model provider directly: the wrapped CrewAI `BaseLLM` and `BaseTool` instances route every LLM and tool call back through the pipeline's own `llm` and `tool` channels, so each agent uses exactly the LLM and tools wired to its own node.
Expand Down Expand Up @@ -82,7 +82,7 @@ The Manager requires at least one connected CrewAI Subagent on the `crewai` chan
1. Fans out a `describe` invoke to each node on the `crewai` channel individually. Each Subagent responds with its role, goal, backstory, task description, expected output, instructions, and a handle to its own engine channels.
2. Builds a CrewAI `Agent` + `Task` per descriptor (`max_iter=5`, `allow_delegation=False`), routing LLM and tool calls back through that sub-agent's own `llm`/`tool` channels. The sub-agents' channels are fully independent of the Manager's.
3. Builds the manager agent (`allow_delegation=True`, `max_iter=5`) from this node's `llm` channel and config. The user's prompt is placed in the manager's backstory as background context, keeping the goal generic.
4. Kicks off a `Process.hierarchical` Crew with `planning=True` and returns the synthesized result.
4. Kicks off a `Process.hierarchical` Crew and returns the synthesized result. Planning runs only when **Crew Planning** is enabled.

### Result extraction

Expand All @@ -100,6 +100,8 @@ Every CrewAI bus event from a kickoff (crew/task/agent lifecycle, tool usage, LL

- When the LLM uses native function calling, CrewAI invokes tools synchronously on the shared kickoff loop, so tool calls serialize across concurrent runs in that path. The ReAct tool path (`_arun`) offloads to a thread and does not block the loop.
- CrewAI 1.14.x planning and delegation are patched at import time to run safely inside the shared asyncio loop. Without these patches, hierarchical crews raise `RuntimeError` when they detect a running event loop.
- **Crew Planning is off by default.** CrewAI's planner runs a task with `output_pydantic` set and drives it with the host LLM wrapper, but the host channel is text-in / text-out and cannot promise JSON. When the plan text does not parse, CrewAI's converter dead-ends and `planning_handler` raises `Failed to get the Planning output`, failing a run that would otherwise have succeeded. Enable it only with a model that reliably emits well-formed JSON.
- The LLM wrapper reports `supports_function_calling() == False`, so every tool call takes CrewAI's ReAct path. The host channel returns plain text and ignores `tools` / `response_model`, so claiming native tool support would advertise a capability the wrapper cannot honour.

---

Expand Down
146 changes: 105 additions & 41 deletions nodes/src/nodes/agent_crewai/crewai_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,26 @@ async def acall(
**kwargs,
)

def supports_function_calling(self) -> bool:
# The host channel is text-in / text-out: `call` ignores `tools`,
# `available_functions` and `response_model` and always returns a
# string, so claiming native tool support would send CrewAI down a
# path this wrapper cannot honour. Returning False keeps every
# tool call on the ReAct path that `_build_crew_tools` targets.
#
# This override is REQUIRED, not just a preference. CrewAI defines
# supports_function_calling() on crewai.llm.LLM and on each provider
# completion class, but NOT on BaseLLM. Most callers probe it with
# hasattr(), yet several do not -- crewai/utilities/converter.py
# to_pydantic/ato_pydantic/to_json, crewai/tools/tool_usage.py
# _function_calling, reasoning_handler, task_evaluator. Without this
# method those raise AttributeError, which converter.py swallows in a
# broad `except Exception` and re-reports as the misleading
# "Failed to convert text into a Pydantic model due to error: ...".
# The hierarchical manager hits that path on every kickoff, because
# Crew(planning=True) runs a planner task with output_pydantic set.
return False

return HostInvokeLLM()

def _build_crew_tools(
Expand All @@ -304,9 +324,10 @@ def _build_crew_tools(
"""
Convert host tool descriptors into CrewAI BaseTool instances.

Each tool's JSON Schema is embedded in the description so CrewAI can
pass structured arguments. A dynamic Pydantic args_schema is built per
tool to preserve real parameter names through CrewAI's argument filter.
A dynamic Pydantic args_schema is built per tool to preserve real
parameter names through CrewAI's argument filter. The JSON Schema is not
copied into the description: CrewAI renders args_schema into the prompt
itself, so a second copy would give the model two contracts to reconcile.

The inner `HostTool` subclass captures `self` and `context` via closure
on this method so its `_run` / `_arun` methods can call
Expand All @@ -318,49 +339,101 @@ def _build_crew_tools(
outer_self = self
outer_context = context

class _ToolInput(BaseModel):
input: Any = Field(default=None, description='Tool input payload')
model_config = ConfigDict(extra='allow')
# JSON Schema type -> Python annotation. CrewAI renders the generated model's
# JSON schema straight into the ReAct prompt as the "Tool Arguments:" block
# (crewai/tools/structured_tool.py format_description_for_llm), so an
# untyped model shows the LLM argument names with no types at all. Typing the
# fields also lets pydantic coerce "123" -> 123 instead of rejecting it.
# Nesting stays shallow on purpose: CrewAI only renders and filters the top
# level, and a deep model would reject payloads the host tool accepts through
# its own `extra` passthrough.
_JSON_TYPE_MAP: Dict[str, Any] = {
'string': str,
'integer': int,
'number': float,
'boolean': bool,
'array': List[Any],
'object': Dict[str, Any],
}

class _NoArgsInput(BaseModel):
"""Schema for tools that declare no parameters."""

model_config = ConfigDict(extra='ignore')

def _annotation_for(prop: Dict[str, Any]) -> Any:
"""Map a JSON Schema property to a Python annotation, defaulting to Any.

`Any` is the deliberate fallback for unions, `null`, and anything the map
does not cover — a wrong guess here would reject valid tool calls, which
is worse than leaving the field untyped.

The non-`str` check is what makes that fallback reachable for unions:
JSON Schema spells a nullable field as `"type": ["string", "null"]`, and
handing that list to `dict.get` raises TypeError on the unhashable key
rather than returning the default — one nullable property would abort the
whole `_build_crew_tools` loop.
"""
prop_type = prop.get('type')
return _JSON_TYPE_MAP.get(prop_type, Any) if isinstance(prop_type, str) else Any

def _make_args_schema(input_schema: Optional[Dict[str, Any]]) -> type[BaseModel]:
"""
Build a dynamic Pydantic model from a JSON Schema so that
CrewAI's argument filter preserves real tool parameters.

The filter matters: crewai/tools/tool_usage.py intersects the model's
emitted argument keys with this schema's property names and silently drops
everything else. A name the schema does not declare is lost before
validation, which then reports every required field as missing.
"""
if not isinstance(input_schema, dict):
return _ToolInput
props = input_schema.get('properties', {})
if not props:
return _ToolInput
required_keys = set(input_schema.get('required', []))
return _NoArgsInput
props = input_schema.get('properties')
if not isinstance(props, dict) or not props:
return _NoArgsInput
required = input_schema.get('required') or []
required_keys = set(required) if isinstance(required, (list, tuple, set)) else set()

field_defs: Dict[str, Any] = {}
for key, prop in props.items():
if not isinstance(key, str) or not key:
continue
if not isinstance(prop, dict):
prop = {}
desc = prop.get('description', '')
annotation = _annotation_for(prop)
if key in required_keys:
field_defs[key] = (Any, Field(..., description=desc))
field_defs[key] = (annotation, Field(..., description=desc))
else:
default = prop.get('default', None)
field_defs[key] = (Any, Field(default=default, description=desc))
# Optional args accept an explicit null as well as omission —
# models routinely send "field": null for "not applicable".
field_defs[key] = (
Optional[annotation],
Field(default=prop.get('default', None), description=desc),
)
if not field_defs:
return _NoArgsInput
try:
return create_model(
'_DynToolInput',
__config__=ConfigDict(extra='ignore'),
**field_defs,
)
except Exception:
return _ToolInput
return _NoArgsInput

class HostTool(BaseTool):
name: str
description: str
args_schema: type[BaseModel] = _ToolInput
args_schema: type[BaseModel] = _NoArgsInput

def __repr__(self) -> str:
# Strip JSON schema suffix and CrewAI-reformatted header so planning
# prompts see a clean one-liner — same as native CrewAI tool reprs.
desc = self.description.split('\n\nTool input schema (JSON):')[0]
if '\nTool Description: ' in desc:
desc = desc.split('\nTool Description: ', 1)[1].split('\n')[0]
# Keep planning prompts to a clean one-liner, same as native CrewAI
# tool reprs. CrewAI composes its own "Tool Name / Tool Arguments /
# Tool Description" block on demand via `formatted_description` and no
# longer rewrites `description`, so only the first line is needed.
desc = self.description.split('\n')[0]
return f'Tool(name={self.name!r}, description={desc!r})'

__str__ = __repr__
Expand All @@ -377,18 +450,12 @@ def _run(self, **framework_args: Any) -> str:
return safe_str(out)

async def _arun(self, **framework_args: Any) -> str:
# The ReAct tool path goes tool.ainvoke -> _arun, so this is what
# gets called when the LLM does NOT use native function calling.
# Wraps the existing sync _run via to_thread to avoid blocking
# the shared kickoff loop.
#
# NOTE: the native tools path (used by GPT-4 / Claude / any LLM
# with supports_function_calling()==True) bypasses _arun entirely
# and calls _run synchronously via available_functions[name].
# See the "Known Limitations" section of the plan -- tool calls
# serialize on the loop in that path. Override
# supports_function_calling()->False on HostInvokeLLM to force
# the ReAct path if full tool concurrency is required.
# Kept for callers that reach BaseTool.arun directly. CrewAI's own
# ReAct path does NOT come through here: to_structured_tool() binds
# func=self._run (the sync method), and CrewStructuredTool.ainvoke
# sees a non-coroutine and dispatches it via run_in_executor. The
# off-loop behaviour this wrapper was written for is therefore already
# provided by CrewAI; this override only matters if that changes back.
return await asyncio.to_thread(self._run, **framework_args)

tools = []
Expand All @@ -399,15 +466,12 @@ async def _arun(self, **framework_args: Any) -> str:
continue
if not desc:
desc = f'Invoke host tool: {name}'
# The schema is NOT appended to the description. CrewAI renders the
# args_schema into the prompt itself as a "Tool Arguments:" JSON Schema
# block, so a second copy here gives the model two contracts to reconcile
# for the same tool — and the two disagree, because CrewAI's copy is run
# through its strict-mode normaliser first.
input_schema = td.get('inputSchema') if isinstance(td, dict) else None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if isinstance(input_schema, dict):
try:
schema_text = json.dumps(input_schema, ensure_ascii=False)
except Exception:
schema_text = ''
if schema_text:
desc = f'{desc}\n\nTool input schema (JSON): {schema_text}'

schema_cls = _make_args_schema(input_schema)
tools.append(HostTool(name=name, description=desc, args_schema=schema_cls))
return tools
5 changes: 4 additions & 1 deletion nodes/src/nodes/agent_crewai/crewai_manager/IGlobal.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"""
CrewAI Manager — global state for the hierarchical multi-agent CrewAI node.

Loads the manager-only connConfig fields (`goal`, `backstory`), ensures the
Loads the manager-only connConfig fields (`goal`, `backstory`, `planning`), ensures the
process-wide CrewAI kickoff runner is running, and instantiates the
`CrewManager` driver held on `self.agent`.

Expand All @@ -29,6 +29,7 @@ class IGlobal(IGlobalBase):
agent: Any = None
goal: str = ''
backstory: str = ''
planning: bool = False
_kickoff_runner: Any = None

def beginGlobal(self) -> None:
Expand All @@ -51,6 +52,7 @@ def beginGlobal(self) -> None:

self.goal = str(conn_config.get('goal') or '').strip()
self.backstory = str(conn_config.get('backstory') or '').strip()
self.planning = bool(conn_config.get('planning', False))

from ..crewai_runner import get_shared_runner

Expand All @@ -64,4 +66,5 @@ def endGlobal(self) -> None:
self.agent = None
self.goal = ''
self.backstory = ''
self.planning = False
self._kickoff_runner = None
4 changes: 2 additions & 2 deletions nodes/src/nodes/agent_crewai/crewai_manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Hierarchical [CrewAI](https://docs.crewai.com/) manager node. Fans out to all co

| Channel | Required | Description |
| -------- | ----------- | ----------------------------------------- |
| `llm` | yes | LLM for the manager agent and planning |
| `llm` | yes | LLM for the manager agent (and planning, when enabled) |
| `crewai` | yes (min 1) | Connected `CrewAI Subagent` nodes (min 1) |

The `crewai` channel accepts only `CrewAI Subagent` nodes. `CrewAI Agent` nodes cannot be used as sub-agents, their `classType` does not include `crewai`. The Manager itself also cannot be nested under another Manager (it lacks `classType: "crewai"`); cross-Manager composition is supported via `tool.run_agent` instead.
Expand All @@ -45,6 +45,6 @@ By default only **Instructions** is shown. Toggle **Advanced Mode** to expose th
1. Fans out `describe` to each connected `CrewAI Subagent` node individually
2. Each `CrewAI Subagent` responds with its role, goal, backstory, task description, expected output, and tools
3. The manager builds an `Agent + Task` per sub-agent, routing LLM and tool calls back through that sub-agent's own pipeline channels
4. Kicks off a hierarchical Crew with `planning=True` using the manager's LLM
4. Kicks off a hierarchical Crew, running CrewAI's planner with the manager's LLM only when **Crew Planning** is enabled (off by default)

The node raises an error at runtime if no sub-agents are connected or none respond to `describe`.
Loading
Loading