Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion docs/mcp/tools/automations-and-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ On **create**, if the caller omits `condition`, the MCP layer supplies `DEFAULT_
| `create_table_record` | `{ "tableId": "<table_id>", "fieldsAttributes": [...] }` (`pipeId` not required; MCP does not check table `fieldId` values against the pipe — use `get_table` / `get_table_record`.) |
| `send_email_template` | `{ "emailTemplateId": "<template_id>" }` (optional: `allowTemplateModifications` boolean; MCP does not verify that the template ID exists.) |

Optional inside `actionParams.aiBehaviorParams`: **`capabilitiesAttributes`** — list of capability entries (e.g. types `advanced_ocr`, `web_search`). Passed through to the API; the MCP does not deeply validate capability config.
Optional inside `actionParams.aiBehaviorParams`:

- **`capabilitiesAttributes`** — list of capability entries, each exactly `{ "capabilityType": "<type>", "enabled": true|false }`. Both keys are required and no other keys are accepted; legacy shapes (bare string lists, `{ "type": ... }`) are rejected. Common `capabilityType` values: `advanced_ocr` (product name IDP / Intelligent Document Processing), `math_operations` (Calculations & Analysis), `web_search`, `web_scraping`, `max_effort`. `capabilityType` is not checked against a fixed set — any value passes through and the API validates the enum on write. Validation checks shape only, not plan/feature entitlement — a capability may still require organization-level enablement to take effect.
- **`providerId`** / **`systemProviderId`** — select the behavior's LLM provider; set at most one (reads resolve a single active provider). Discover IDs via the organization's AI settings in the Pipefy UI.

### Optional `eventParams` (trigger filters)

Expand Down
61 changes: 61 additions & 0 deletions packages/cli/tests/test_cli_agent_automation_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from _shared.ai_agent_test_payloads import minimal_behavior_dict
from typer.testing import CliRunner

from pipefy_cli.main import app
Expand Down Expand Up @@ -65,6 +66,66 @@ def test_agent_validate_behaviors_json(
assert body.get("success") is True


def _behavior_with_ai_params(**ai_behavior_params) -> dict:
behavior = minimal_behavior_dict()
behavior["actionParams"]["aiBehaviorParams"].update(ai_behavior_params)
return behavior


def test_agent_create_rejects_legacy_capability_shape(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
oauth_env("ag-cap")
behavior = _behavior_with_ai_params(
capabilitiesAttributes=[{"type": "advanced_ocr"}]
)
r = runner.invoke(
app,
[
"agent",
"create",
"--repo-uuid",
"repo-1",
"--name",
"Agent",
"--instruction",
"Purpose",
"--pipe",
"1",
"--behaviors",
json.dumps([behavior]),
],
)
assert r.exit_code != 0
assert "capabilityType" in r.stderr


def test_agent_create_rejects_both_provider_ids(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
oauth_env("ag-prov")
behavior = _behavior_with_ai_params(providerId="prov-1", systemProviderId="sys-1")
r = runner.invoke(
app,
[
"agent",
"create",
"--repo-uuid",
"repo-1",
"--name",
"Agent",
"--instruction",
"Purpose",
"--pipe",
"1",
"--behaviors",
json.dumps([behavior]),
],
)
assert r.exit_code != 0
assert "at most one" in r.stderr


def test_ai_automation_validate_prompt_json(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
Expand Down
25 changes: 20 additions & 5 deletions packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,17 @@ async def create_ai_agent(
- ``send_email_template`` → ``{"emailTemplateId": "<template_id>"}``;
optional ``allowTemplateModifications`` (boolean).

Optional ``actionParams.aiBehaviorParams.capabilitiesAttributes`` (list of strings), e.g.
``advanced_ocr``, ``web_search`` — pass-through; the API validates capability types.
Optional ``actionParams.aiBehaviorParams.capabilitiesAttributes`` — a list of
capability entries, each exactly ``{"capabilityType": "<type>", "enabled": true|false}``
(legacy string lists / ``{"type": ...}`` / extra keys are rejected). Common types:
``advanced_ocr`` (product name IDP / Intelligent Document Processing),
``math_operations`` (Calculations & Analysis), ``web_search``, ``web_scraping``,
``max_effort``; unknown types pass through — the API validates the enum and
entitlement on write (a capability may require organization-level enablement).

Optional ``actionParams.aiBehaviorParams.providerId`` / ``systemProviderId`` select the
behavior's LLM provider; set at most one. Discover IDs via the organization's AI
settings in the Pipefy UI.

Optional ``eventParams`` per behavior (filters when the trigger fires):
- ``field_updated`` event → ``{"triggerFieldIds": ["<field_id>"]}`` to fire only on specific fields.
Expand Down Expand Up @@ -366,8 +375,9 @@ async def update_ai_agent(
- ``send_email_template`` → ``{"emailTemplateId": "<template_id>"}``;
optional ``allowTemplateModifications`` (boolean).

Optional ``actionParams.aiBehaviorParams.capabilitiesAttributes`` (list of strings), e.g.
``advanced_ocr``, ``web_search`` — pass-through; the API validates capability types.
Optional ``capabilitiesAttributes`` / ``providerId`` / ``systemProviderId`` inside
``actionParams.aiBehaviorParams`` — same rules as ``create_ai_agent``; see its
docstring.

Args:
uuid: UUID of the agent to update.
Expand Down Expand Up @@ -588,7 +598,12 @@ async def validate_ai_agent_behaviors(
pipe field-ID checks on its metadata.

Runs Pydantic model validation (same as the mutation tools) plus cross-references
against live pipe data. Does not persist anything.
against live pipe data. Does not persist anything. Model validation rejects
malformed ``capabilitiesAttributes`` entries (each must be
``{"capabilityType": "<type>", "enabled": true|false}``) and a behavior that sets
both ``providerId`` and ``systemProviderId``. ``capabilityType`` values are not
checked against a known-enum set — any value passes through and the API validates
the enum on write (capabilities may also require organization-level enablement).

Field IDs are matched against start-form fields and phase fields (via
``get_phase_fields`` per phase), accepting both slug ``id`` and numeric
Expand Down
52 changes: 52 additions & 0 deletions packages/mcp/tests/tools/test_ai_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,58 @@ async def test_validation_error_returns_error_payload(
assert payload["success"] is False
assert "error" in payload

async def test_rejects_legacy_capability_shape(
self,
client_session,
mock_pipefy_client,
extract_payload,
):
behavior = minimal_behavior_dict(name="B1")
behavior["actionParams"]["aiBehaviorParams"]["capabilitiesAttributes"] = [
{"type": "advanced_ocr"}
]
async with client_session as session:
result = await session.call_tool(
"create_ai_agent",
{
"name": "Agent",
"repo_uuid": "repo-456",
"instruction": "Purpose",
"behaviors": [behavior],
},
)
assert result.isError is False
mock_pipefy_client.create_ai_agent.assert_not_called()
payload = extract_payload(result)
assert payload["success"] is False
assert "capabilityType" in str(payload["error"])

async def test_rejects_both_provider_ids(
self,
client_session,
mock_pipefy_client,
extract_payload,
):
behavior = minimal_behavior_dict(name="B1")
abp = behavior["actionParams"]["aiBehaviorParams"]
abp["providerId"] = "prov-1"
abp["systemProviderId"] = "sys-1"
async with client_session as session:
result = await session.call_tool(
"create_ai_agent",
{
"name": "Agent",
"repo_uuid": "repo-456",
"instruction": "Purpose",
"behaviors": [behavior],
},
)
assert result.isError is False
mock_pipefy_client.create_ai_agent.assert_not_called()
payload = extract_payload(result)
assert payload["success"] is False
assert "at most one" in str(payload["error"])

async def test_create_and_configure_success(
self,
client_session,
Expand Down
114 changes: 104 additions & 10 deletions packages/sdk/src/pipefy_sdk/models/ai_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from __future__ import annotations

from typing import Self
from typing import Annotated, Self

from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator

from pipefy_sdk.models.validators import NonBlankStr

Expand Down Expand Up @@ -100,6 +100,72 @@ def _validate_move_card_metadata(metadata: dict) -> None:
)


_CAPABILITY_CANONICAL_SHAPE = '{"capabilityType": "<type>", "enabled": true|false}'


def _validate_capability_entries(
capabilities: list[AiBehaviorCapabilityAttributes] | None,
) -> None:
"""Enforce the canonical capability wire shape on each entry.

Every entry must carry a non-empty ``capabilityType`` string, a boolean
``enabled``, and no other keys (the GraphQL input type is closed — an unknown
key fails the whole mutation with an opaque coercion error, so it is rejected
here with a clear one). Legacy shapes (bare string lists, ``{"type": ...}``)
are rejected: a string list fails Pydantic list coercion before this runs, and
``{"type": ...}`` lands here with ``capability_type`` unset. Enum membership is
intentionally *not* checked — any ``capabilityType`` value passes through,
because the capability set grows over time and the API validates the enum
server-side on write.
"""
if not capabilities:
return
for i, cap in enumerate(capabilities):
ctype = cap.capability_type
if not ctype or not ctype.strip():
raise ValueError(
f"capabilitiesAttributes[{i}] requires a non-empty 'capabilityType'. "
f"Use the canonical shape {_CAPABILITY_CANONICAL_SHAPE}; legacy shapes "
f'(string lists, {{"type": ...}}) are not accepted.'
)
if cap.enabled is None:
raise ValueError(
f"capabilitiesAttributes[{i}] (capabilityType '{ctype}') requires a "
f"boolean 'enabled'. Use the canonical shape "
f"{_CAPABILITY_CANONICAL_SHAPE}."
)
if cap.model_extra:
unknown = ", ".join(sorted(cap.model_extra))
raise ValueError(
f"capabilitiesAttributes[{i}] (capabilityType '{ctype}') has unknown "
f"key(s): {unknown}. The API accepts exactly 'capabilityType' and "
f"'enabled'; unknown keys make the whole mutation fail."
)


def _reject_non_mapping_capability_entries(value: object) -> object:
"""Give bare-string / non-object capability entries the canonical-shape error.

A legacy string list fails ``AiBehaviorCapabilityAttributes`` coercion with an
opaque ``model_type`` error before :func:`_validate_capability_entries` can run,
so the caller never sees the canonical-shape guidance. Catch non-object entries
here to surface the same actionable message as the other legacy shapes; object
entries (including the legacy ``{"type": ...}`` shape) pass through untouched to
normal coercion and the after-validator.
"""
if not isinstance(value, list):
return value
for i, entry in enumerate(value):
if not isinstance(entry, (dict, AiBehaviorCapabilityAttributes)):
raise ValueError(
f"capabilitiesAttributes[{i}] must be an object, not "
f"{type(entry).__name__}. Use the canonical shape "
f"{_CAPABILITY_CANONICAL_SHAPE}; legacy shapes (string lists, "
f'{{"type": ...}}) are not accepted.'
)
return value


def _validate_action_metadata(action: AiBehaviorActionAttributes) -> None:
"""Validate metadata for a single action based on its actionType.

Expand All @@ -121,9 +187,12 @@ def _validate_action_metadata(action: AiBehaviorActionAttributes) -> None:
class AiBehaviorCapabilityAttributes(BaseModel):
"""One entry in ``aiBehaviorParams.capabilitiesAttributes``.

The typed shell only; no shape or enum validation lives here. Legacy shapes
(e.g. ``{"type": "advanced_ocr"}``) round-trip verbatim through ``extra="allow"``.
Capability shape/enum rules are declared on this model in a follow-up.
A lenient typed shell: it parses any dict (``extra="allow"`` keeps unknown keys),
so read/normalization paths (:class:`BehaviorPayload`) accept whatever the API
stores — reads always return ``{capabilityType, enabled}``, both non-null. The
canonical-shape rules (``capabilityType`` + boolean ``enabled`` required, no
unknown keys) are enforced at the input boundary by :class:`BehaviorInput`, not
here; enum membership is not checked client-side (the API validates it on write).
"""

model_config = ConfigDict(populate_by_name=True, extra="allow")
Expand Down Expand Up @@ -164,9 +233,10 @@ class AiBehaviorParams(BaseModel):
actions_attributes: list[AiBehaviorActionAttributes] | None = Field(
default=None, alias="actionsAttributes"
)
capabilities_attributes: list[AiBehaviorCapabilityAttributes] | None = Field(
default=None, alias="capabilitiesAttributes"
)
capabilities_attributes: Annotated[
list[AiBehaviorCapabilityAttributes] | None,
BeforeValidator(_reject_non_mapping_capability_entries),
] = Field(default=None, alias="capabilitiesAttributes")
data_source_ids: list[str] | None = Field(default=None, alias="dataSourceIds")
referenced_field_ids: list[str] | None = Field(
default=None, alias="referencedFieldIds"
Expand Down Expand Up @@ -226,8 +296,14 @@ class BehaviorInput(BaseModel):
Optional ``eventParams`` configures the trigger.

Optional ``actionParams.aiBehaviorParams.capabilitiesAttributes`` is a list of capability
entries the API accepts (e.g. ``advanced_ocr``, ``web_search``). No extra structural
validation here — the API enforces capability shapes.
entries in the canonical shape ``{"capabilityType": "<type>", "enabled": true|false}``.
Both keys are required per entry; legacy shapes (bare string lists, ``{"type": ...}``)
are rejected. ``capabilityType`` values are not checked against a known-enum set
(any value passes through; the API validates the enum on write).

Optional ``providerId`` / ``systemProviderId`` select the behavior's LLM provider; at
most one may be set (reads resolve a single active provider, so co-presence is
unverifiable).

For each action dict, known ``actionType`` values get ``metadata`` checks:
``update_card`` / ``create_card`` / ``create_connected_card`` need ``pipeId`` and non-empty
Expand Down Expand Up @@ -273,6 +349,24 @@ def ai_behavior_must_include_at_least_one_action(self) -> Self:
)
for action in actions:
_validate_action_metadata(action)
_validate_capability_entries(abp.capabilities_attributes)
for wire_name, value in (
("providerId", abp.provider_id),
("systemProviderId", abp.system_provider_id),
):
# Blank strings would dodge the co-presence check below (falsy) yet
# still be serialized to the API (exclude_none keeps them).
if value is not None and not value.strip():
raise ValueError(
f"aiBehaviorParams.{wire_name} must be a non-empty string when "
f"set; omit the field to leave the provider unset."
)
if abp.provider_id and abp.system_provider_id:
raise ValueError(
"A behavior may set at most one of providerId / systemProviderId. "
"Reads resolve a single active provider per behavior, so co-presence "
"is unverifiable — send only the one that applies."
)
return self


Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/src/pipefy_sdk/queries/ai_agent_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
instruction
dataSourceIds
referencedFieldIds
providerId
systemProviderId
capabilitiesAttributes {
capabilityType
enabled
}
actionsAttributes {
id
name
Expand Down Expand Up @@ -161,6 +167,12 @@
instruction
dataSourceIds
referencedFieldIds
providerId
systemProviderId
capabilitiesAttributes {
capabilityType
enabled
}
actionsAttributes {
id
name
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/tests/_shared/ai_agent_test_payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ def mock_api_behavior_response(
"instruction": "Analyze the card and fill summary.",
"dataSourceIds": [],
"referencedFieldIds": [],
"providerId": None,
"systemProviderId": None,
"capabilitiesAttributes": [
{"capabilityType": "advanced_ocr", "enabled": True},
],
"actionsAttributes": [
{
"id": "456",
Expand Down
Loading
Loading