From 6a7321f50e5c494ade81be5d7b035d1428e67704 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:44:53 -0300 Subject: [PATCH 1/2] feat: harden behavior capability and provider selection fields Enforce the canonical capabilitiesAttributes shape ({capabilityType, enabled}, no extra keys) and reject legacy string-list / {type: ...} shapes at the SDK, MCP, and CLI boundaries via the typed BehaviorInput model. capabilityType is not checked against a fixed set: any value passes through and the API validates the enum on write, so new capabilities work without a toolkit update. Accept optional providerId / systemProviderId with at most one set per behavior (reads resolve a single active provider), rejecting blank-when-set values before they can reach the wire. Extend the GET and update GraphQL selections so capabilitiesAttributes, providerId, and systemProviderId round-trip. Closes #410. --- docs/mcp/tools/automations-and-ai.md | 5 +- .../tests/test_cli_agent_automation_smoke.py | 61 +++++++++++ .../src/pipefy_mcp/tools/ai_agent_tools.py | 25 ++++- .../mcp/tests/tools/test_ai_agent_tools.py | 52 +++++++++ .../sdk/src/pipefy_sdk/models/ai_agent.py | 80 +++++++++++++- .../pipefy_sdk/queries/ai_agent_queries.py | 12 +++ .../tests/_shared/ai_agent_test_payloads.py | 5 + packages/sdk/tests/models/test_ai_agent.py | 101 +++++++++++++++++- .../tests/services/test_ai_agent_service.py | 15 +++ skills/ai-agents/pipefy-ai-agents/SKILL.md | 27 ++++- 10 files changed, 367 insertions(+), 16 deletions(-) diff --git a/docs/mcp/tools/automations-and-ai.md b/docs/mcp/tools/automations-and-ai.md index 2b6c0625..77bd40da 100644 --- a/docs/mcp/tools/automations-and-ai.md +++ b/docs/mcp/tools/automations-and-ai.md @@ -179,7 +179,10 @@ On **create**, if the caller omits `condition`, the MCP layer supplies `DEFAULT_ | `create_table_record` | `{ "tableId": "", "fieldsAttributes": [...] }` (`pipeId` not required; MCP does not check table `fieldId` values against the pipe — use `get_table` / `get_table_record`.) | | `send_email_template` | `{ "emailTemplateId": "" }` (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": "", "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) diff --git a/packages/cli/tests/test_cli_agent_automation_smoke.py b/packages/cli/tests/test_cli_agent_automation_smoke.py index dc50042b..a0a63516 100644 --- a/packages/cli/tests/test_cli_agent_automation_smoke.py +++ b/packages/cli/tests/test_cli_agent_automation_smoke.py @@ -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 @@ -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 ): diff --git a/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py b/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py index dd2ba14c..094d68cd 100644 --- a/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py @@ -209,8 +209,17 @@ async def create_ai_agent( - ``send_email_template`` → ``{"emailTemplateId": ""}``; 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": "", "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": [""]}`` to fire only on specific fields. @@ -366,8 +375,9 @@ async def update_ai_agent( - ``send_email_template`` → ``{"emailTemplateId": ""}``; 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. @@ -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": "", "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 diff --git a/packages/mcp/tests/tools/test_ai_agent_tools.py b/packages/mcp/tests/tools/test_ai_agent_tools.py index 3d457126..a9c6bfe7 100644 --- a/packages/mcp/tests/tools/test_ai_agent_tools.py +++ b/packages/mcp/tests/tools/test_ai_agent_tools.py @@ -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, diff --git a/packages/sdk/src/pipefy_sdk/models/ai_agent.py b/packages/sdk/src/pipefy_sdk/models/ai_agent.py index 226f0df5..45057fd8 100644 --- a/packages/sdk/src/pipefy_sdk/models/ai_agent.py +++ b/packages/sdk/src/pipefy_sdk/models/ai_agent.py @@ -100,6 +100,49 @@ def _validate_move_card_metadata(metadata: dict) -> None: ) +_CAPABILITY_CANONICAL_SHAPE = '{"capabilityType": "", "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 _validate_action_metadata(action: AiBehaviorActionAttributes) -> None: """Validate metadata for a single action based on its actionType. @@ -121,9 +164,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") @@ -226,8 +272,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": "", "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 @@ -273,6 +325,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 diff --git a/packages/sdk/src/pipefy_sdk/queries/ai_agent_queries.py b/packages/sdk/src/pipefy_sdk/queries/ai_agent_queries.py index a07288c9..a75c225c 100644 --- a/packages/sdk/src/pipefy_sdk/queries/ai_agent_queries.py +++ b/packages/sdk/src/pipefy_sdk/queries/ai_agent_queries.py @@ -43,6 +43,12 @@ instruction dataSourceIds referencedFieldIds + providerId + systemProviderId + capabilitiesAttributes { + capabilityType + enabled + } actionsAttributes { id name @@ -161,6 +167,12 @@ instruction dataSourceIds referencedFieldIds + providerId + systemProviderId + capabilitiesAttributes { + capabilityType + enabled + } actionsAttributes { id name diff --git a/packages/sdk/tests/_shared/ai_agent_test_payloads.py b/packages/sdk/tests/_shared/ai_agent_test_payloads.py index 155dba8c..1eb482e9 100644 --- a/packages/sdk/tests/_shared/ai_agent_test_payloads.py +++ b/packages/sdk/tests/_shared/ai_agent_test_payloads.py @@ -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", diff --git a/packages/sdk/tests/models/test_ai_agent.py b/packages/sdk/tests/models/test_ai_agent.py index 8149ab6e..4d349748 100644 --- a/packages/sdk/tests/models/test_ai_agent.py +++ b/packages/sdk/tests/models/test_ai_agent.py @@ -571,16 +571,109 @@ def test_metadata_rejects_non_bool_allow_template_modifications(): @pytest.mark.unit -def test_behavior_input_accepts_capabilities_attributes_on_ai_behavior_params(): +def test_behavior_input_accepts_canonical_capabilities_attributes(): payload = minimal_behavior_dict() abp = payload["actionParams"]["aiBehaviorParams"] - abp["capabilitiesAttributes"] = [{"type": "advanced_ocr"}, {"type": "web_search"}] + abp["capabilitiesAttributes"] = [ + {"capabilityType": "advanced_ocr", "enabled": True}, + {"capabilityType": "web_search", "enabled": False}, + ] inp = BehaviorInput.model_validate(payload) caps = inp.action_params.ai_behavior_params.capabilities_attributes assert [c.model_dump(by_alias=True, exclude_none=True) for c in caps] == [ - {"type": "advanced_ocr"}, - {"type": "web_search"}, + {"capabilityType": "advanced_ocr", "enabled": True}, + {"capabilityType": "web_search", "enabled": False}, + ] + + +@pytest.mark.unit +def test_behavior_input_rejects_unknown_capability_keys(): + """The GraphQL capability input is closed; a typo'd key must fail clearly here.""" + payload = minimal_behavior_dict() + abp = payload["actionParams"]["aiBehaviorParams"] + abp["capabilitiesAttributes"] = [ + {"capabilityType": "advanced_ocr", "enabled": True, "enable": True} + ] + with pytest.raises(ValidationError, match="unknown key"): + BehaviorInput.model_validate(payload) + + +@pytest.mark.unit +@pytest.mark.parametrize("field", ["providerId", "systemProviderId"]) +def test_behavior_input_rejects_blank_provider_id(field): + """Blank provider ids would dodge the co-presence check yet reach the wire.""" + payload = minimal_behavior_dict() + payload["actionParams"]["aiBehaviorParams"][field] = " " + with pytest.raises(ValidationError, match="non-empty"): + BehaviorInput.model_validate(payload) + + +@pytest.mark.unit +def test_behavior_input_accepts_unknown_capability_type(): + """Unknown capabilityType values are not checked client-side; the API validates the enum on write.""" + payload = minimal_behavior_dict() + abp = payload["actionParams"]["aiBehaviorParams"] + abp["capabilitiesAttributes"] = [ + {"capabilityType": "future_capability", "enabled": True} ] + inp = BehaviorInput.model_validate(payload) + caps = inp.action_params.ai_behavior_params.capabilities_attributes + assert caps[0].capability_type == "future_capability" + + +@pytest.mark.unit +def test_behavior_input_rejects_legacy_type_capability_shape(): + payload = minimal_behavior_dict() + abp = payload["actionParams"]["aiBehaviorParams"] + abp["capabilitiesAttributes"] = [{"type": "advanced_ocr"}] + with pytest.raises(ValidationError, match="capabilityType"): + BehaviorInput.model_validate(payload) + + +@pytest.mark.unit +def test_behavior_input_rejects_capability_string_list(): + payload = minimal_behavior_dict() + abp = payload["actionParams"]["aiBehaviorParams"] + abp["capabilitiesAttributes"] = ["advanced_ocr", "web_search"] + with pytest.raises(ValidationError): + BehaviorInput.model_validate(payload) + + +@pytest.mark.unit +def test_behavior_input_rejects_capability_missing_enabled(): + payload = minimal_behavior_dict() + abp = payload["actionParams"]["aiBehaviorParams"] + abp["capabilitiesAttributes"] = [{"capabilityType": "advanced_ocr"}] + with pytest.raises(ValidationError, match="enabled"): + BehaviorInput.model_validate(payload) + + +@pytest.mark.unit +def test_behavior_input_accepts_provider_id_alone(): + payload = minimal_behavior_dict() + payload["actionParams"]["aiBehaviorParams"]["providerId"] = "prov-1" + inp = BehaviorInput.model_validate(payload) + assert inp.action_params.ai_behavior_params.provider_id == "prov-1" + assert inp.action_params.ai_behavior_params.system_provider_id is None + + +@pytest.mark.unit +def test_behavior_input_accepts_system_provider_id_alone(): + payload = minimal_behavior_dict() + payload["actionParams"]["aiBehaviorParams"]["systemProviderId"] = "sys-1" + inp = BehaviorInput.model_validate(payload) + assert inp.action_params.ai_behavior_params.system_provider_id == "sys-1" + assert inp.action_params.ai_behavior_params.provider_id is None + + +@pytest.mark.unit +def test_behavior_input_rejects_both_provider_ids(): + payload = minimal_behavior_dict() + abp = payload["actionParams"]["aiBehaviorParams"] + abp["providerId"] = "prov-1" + abp["systemProviderId"] = "sys-1" + with pytest.raises(ValidationError, match="at most one"): + BehaviorInput.model_validate(payload) # --- snake_case / camelCase normalization --- diff --git a/packages/sdk/tests/services/test_ai_agent_service.py b/packages/sdk/tests/services/test_ai_agent_service.py index 7131405a..b695a2f1 100644 --- a/packages/sdk/tests/services/test_ai_agent_service.py +++ b/packages/sdk/tests/services/test_ai_agent_service.py @@ -44,6 +44,21 @@ def test_update_ai_agent_mutation_includes_email_template_metadata_fields(): assert "allowTemplateModifications" in printed +@pytest.mark.unit +@pytest.mark.parametrize( + "document", + [GET_AI_AGENT_QUERY.document, UPDATE_AI_AGENT_MUTATION.document], + ids=["get", "update"], +) +def test_ai_agent_selection_round_trips_capabilities_and_provider_ids(document): + printed = print_ast(document) + assert "capabilitiesAttributes" in printed + assert "capabilityType" in printed + assert "enabled" in printed + assert "providerId" in printed + assert "systemProviderId" in printed + + def _make_behavior_dict(instruction="", actions=None): result = { "name": "Test Behavior", diff --git a/skills/ai-agents/pipefy-ai-agents/SKILL.md b/skills/ai-agents/pipefy-ai-agents/SKILL.md index 8c0b356f..7967b671 100644 --- a/skills/ai-agents/pipefy-ai-agents/SKILL.md +++ b/skills/ai-agents/pipefy-ai-agents/SKILL.md @@ -120,6 +120,31 @@ Use real values from `get_pipe` / `get_start_form_fields` for your org. Placehol { "pipeId": "900000301", "fieldsAttributes": [{ "fieldId": "title", "inputMode": "fill_with_ai", "value": "" }] } ``` +### 5b — Optional: capabilities and LLM provider + +Inside `actionParams.aiBehaviorParams` a behavior may also carry: + +- **`capabilitiesAttributes`** — advanced tools the behavior can use. Each entry is exactly `{ "capabilityType": "", "enabled": true|false }` (both keys required, no extra keys — bare strings or `{ "type": ... }` are rejected). + + | Product name | `capabilityType` | + |---|---| + | IDP / Intelligent Document Processing | `advanced_ocr` | + | Calculations & Analysis | `math_operations` | + | Web Search | `web_search` | + | Web Scraping | `web_scraping` | + | Max effort | `max_effort` | + + `capabilityType` is not checked against a fixed set — any value passes through and the API validates the enum on write, so new capabilities work without a toolkit update. Validation checks **shape only, not entitlement** — a capability may still require organization-level enablement to have any effect, so a green pre-flight does not guarantee the capability is active for the org. +- **`providerId`** / **`systemProviderId`** — pick the behavior's LLM provider. Set **at most one** (a behavior resolves to a single active provider). Discover valid IDs via the organization's AI settings in the Pipefy UI. + +```json +{ + "instruction": "Extract totals from the attached invoice.", + "capabilitiesAttributes": [{ "capabilityType": "advanced_ocr", "enabled": true }], + "actionsAttributes": [ /* ... */ ] +} +``` + ### 6 — Validate (recommended for complex behaviors) `validate_ai_agent_behaviors(pipe_id, behaviors)` checks: @@ -127,7 +152,7 @@ Use real values from `get_pipe` / `get_start_form_fields` for your org. Placehol - Phase IDs exist - Pipe relations exist for `create_connected_card` - Action types are valid -- Behavior structure passes Pydantic validation +- Behavior structure passes Pydantic validation (including canonical `capabilitiesAttributes` shape and at most one of `providerId` / `systemProviderId`) - Field IDs are checked against start-form and phase fields, accepting both slug `id` and numeric `internal_id`. Placeholders like `%{field:}` or `%{field:}` are validated but **not rewritten** at this step. ### 7 — Create the agent From 47173d8625030412d5fedfc2ea25c781bd064d81 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:06:07 -0300 Subject: [PATCH 2/2] fix: give string-list capabilities the canonical-shape error A legacy capabilitiesAttributes string list failed AiBehaviorCapabilityAttributes coercion with an opaque Pydantic model_type error before _validate_capability_entries ran, so callers never saw the canonical-shape guidance the other legacy shapes get. Add a BeforeValidator that surfaces the actionable message for non-object entries; object entries (including {"type": ...}) still pass through to the after-validator unchanged. Tighten the string-list test with match=. --- .../sdk/src/pipefy_sdk/models/ai_agent.py | 34 ++++++++++++++++--- packages/sdk/tests/models/test_ai_agent.py | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/pipefy_sdk/models/ai_agent.py b/packages/sdk/src/pipefy_sdk/models/ai_agent.py index 45057fd8..723103b7 100644 --- a/packages/sdk/src/pipefy_sdk/models/ai_agent.py +++ b/packages/sdk/src/pipefy_sdk/models/ai_agent.py @@ -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 @@ -143,6 +143,29 @@ def _validate_capability_entries( ) +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. @@ -210,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" diff --git a/packages/sdk/tests/models/test_ai_agent.py b/packages/sdk/tests/models/test_ai_agent.py index 4d349748..7d006f3a 100644 --- a/packages/sdk/tests/models/test_ai_agent.py +++ b/packages/sdk/tests/models/test_ai_agent.py @@ -635,7 +635,7 @@ def test_behavior_input_rejects_capability_string_list(): payload = minimal_behavior_dict() abp = payload["actionParams"]["aiBehaviorParams"] abp["capabilitiesAttributes"] = ["advanced_ocr", "web_search"] - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match="must be an object, not str"): BehaviorInput.model_validate(payload)