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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **SDK pre-write validation**: `PipefyClient.validate_ai_agent_behaviors` and `PipefyClient.validate_ai_automation_prompt` expose the two read-only validators as client methods, with the MCP tool names and parameters, so an agent that builds its tools from `PipefyClient` can validate before it writes. The MCP tools and CLI commands now call these methods. The `pipefy_sdk.ai_preflight` module functions stay. (#694)

### Fixed

- **Automation listings**: SDK, MCP, and CLI now return trigger IDs, event parameters, conditions, `actionEnabled`, and `disabledReason` for organization and pipe listings, avoiding a detail call per rule to audit its filters and whether the action is enabled. Listings are paged: the API caps a page at 50 rules, so `get_automations` / `pipefy automation list` accept `first` / `after` and report `totalCount` and `hasNextPage` instead of silently returning the first 50. `get_ai_automations` / `pipefy ai-automation list` expose the same page block for the mixed connection they filter. Human `pipefy automation list` prints a table of each row's scalar columns plus the page counts, leaving the nested `event_params` and `condition` to `--json`. Phase-delete preview follows every page of rules, reads their details under a concurrency bound instead of one simultaneous call per rule, and says when the dependents list is a lower bound because a page or a detail read failed. An empty pipe no longer fails `get_automation_logs_by_repo`. The last page of an audit names itself so `11 of 61` is not read as a shortfall. (#612)
Expand Down
9 changes: 9 additions & 0 deletions docs/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ Other exported error types sit outside this root. Catch these by name:
Transport-level failures (connection refused, timeouts) surface as `gql`'s
`TransportError`, which the SDK does not wrap.

## Pre-write validation

Two read-only `PipefyClient` methods dry-run a write before you make it. They have the same names and parameters as the MCP tools, and they never persist anything:

- **`validate_ai_agent_behaviors(pipe_id, behaviors, *, strict_unknown_action_types=True, data_source_ids=None)`** checks a behavior list against the pipe's fields, phases, relations, phase transitions, and knowledge bases. Call it before `create_ai_agent` / `update_ai_agent`.
- **`validate_ai_automation_prompt(pipe_id, prompt, field_ids, event_id=None)`** checks the prompt's `%{internal_id}` references, the output `field_ids`, the optional trigger, and whether AI is enabled for the pipe and the organization. Call it before `create_ai_automation`.

Both return a dict. `valid` is true only when `problems` is empty, and `warnings` holds non-blocking notices. The agent result adds a `message`; the prompt result adds a `field_map` of the referenced field IDs to their labels. A failed pipe read sets `success` to false instead of raising: the agent result then gives the reason in `problems`, and the prompt result carries only `success`, `valid`, and `error`.

## Configuration

OAuth and endpoint variables are documented in **[`../config.md`](../config.md)** and **[`../../.env.example`](../../.env.example)**. Integration tests use `@pytest.mark.integration` and the same `PIPEFY_*` keys from local **`.env`** (e.g. `PIPEFY_PORTAL_ORG_UUID` for portal live tests). Unit tests use fictional ids in **[`../../packages/sdk/tests/_shared/fixture_ids.py`](../../packages/sdk/tests/_shared/fixture_ids.py)** — not production org UUIDs.
Expand Down
10 changes: 3 additions & 7 deletions packages/cli/src/pipefy_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
PipefyClient,
UpdateAiAgentInput,
)
from pipefy_sdk.ai_preflight import validate_ai_agent_behaviors_sdk
from pipefy_sdk.behavior_placeholders import (
expand_behaviors_placeholders,
normalize_pipefy_ai_instruction_tokens,
Expand Down Expand Up @@ -160,8 +159,7 @@ def agent_create(
raise typer.BadParameter(str(exc)) from exc

async def factory(client: PipefyClient):
pre = await validate_ai_agent_behaviors_sdk(
client,
pre = await client.validate_ai_agent_behaviors(
pipe.strip(),
[b.model_dump(by_alias=True) for b in validated.behaviors],
strict_unknown_action_types=strict_unknown,
Expand Down Expand Up @@ -267,8 +265,7 @@ def agent_update(
raise typer.BadParameter(str(exc)) from exc

async def factory(client: PipefyClient):
pre = await validate_ai_agent_behaviors_sdk(
client,
pre = await client.validate_ai_agent_behaviors(
pipe.strip(),
[b.model_dump(by_alias=True) for b in validated.behaviors],
strict_unknown_action_types=strict_unknown,
Expand Down Expand Up @@ -394,8 +391,7 @@ def agent_validate_behaviors(
behavior_list = _parse_behaviors_json(behaviors)

async def factory(client: PipefyClient):
return await validate_ai_agent_behaviors_sdk(
client,
return await client.validate_ai_agent_behaviors(
pipe.strip(),
behavior_list,
strict_unknown_action_types=strict_unknown,
Expand Down
16 changes: 6 additions & 10 deletions packages/cli/src/pipefy_cli/commands/ai_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@
PipefyClient,
UpdateAiAutomationInput,
)
from pipefy_sdk.ai_preflight import (
filter_ai_automation_summaries,
validate_ai_automation_prompt_sdk,
)
from pipefy_sdk.ai_preflight import filter_ai_automation_summaries
from pydantic import ValidationError

from pipefy_cli.commands._common import (
Expand Down Expand Up @@ -166,8 +163,8 @@ def ai_automation_validate_prompt(
fids = _parse_field_ids(field_ids)

async def factory(client: PipefyClient):
return await validate_ai_automation_prompt_sdk(
client, pipe.strip(), prompt, fids, event_id
return await client.validate_ai_automation_prompt(
pipe.strip(), prompt, fids, event_id
)

run_cli_command(ctx, json_out, factory)
Expand Down Expand Up @@ -213,8 +210,8 @@ def ai_automation_create(
skills = list(skills_raw)

async def factory(client: PipefyClient):
pre = await validate_ai_automation_prompt_sdk(
client, pipe.strip(), prompt, fids, event_id
pre = await client.validate_ai_automation_prompt(
pipe.strip(), prompt, fids, event_id
)
_raise_if_prompt_preflight_blocks(pre)
try:
Expand Down Expand Up @@ -304,8 +301,7 @@ async def factory(client: PipefyClient):
"Pass --prompt and --field-ids explicitly."
)
ev = str(row.get("event_id") or "")
pre = await validate_ai_automation_prompt_sdk(
client,
pre = await client.validate_ai_automation_prompt(
pipe.strip(),
effective_prompt,
effective_fids,
Expand Down
29 changes: 23 additions & 6 deletions packages/cli/tests/test_cli_agent_automation_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from __future__ import annotations

import json
from types import MethodType
from unittest.mock import AsyncMock, MagicMock, patch

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

from pipefy_cli.main import app
Expand All @@ -17,6 +19,9 @@ def test_agent_validate_behaviors_json(
):
oauth_env("ag-val")
mock_client = MagicMock()
mock_client.validate_ai_agent_behaviors = MethodType(
PipefyClient.validate_ai_agent_behaviors, mock_client
)
mock_client.get_pipe = AsyncMock(
return_value={"pipe": {"phases": [], "start_form_fields": []}}
)
Expand Down Expand Up @@ -131,6 +136,9 @@ def test_ai_automation_validate_prompt_json(
):
oauth_env("ai-val")
mock_client = MagicMock()
mock_client.validate_ai_automation_prompt = MethodType(
PipefyClient.validate_ai_automation_prompt, mock_client
)
mock_client.get_pipe_with_preferences = AsyncMock(
return_value={
"pipe": {
Expand Down Expand Up @@ -390,8 +398,9 @@ def test_agent_create_happy_path_chains_create_then_update(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=preflight_ok),
),
patch(
Expand Down Expand Up @@ -474,8 +483,9 @@ def test_agent_update_invokes_field_ref_resolution_via_facade(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=preflight_ok),
),
patch(
Expand Down Expand Up @@ -531,8 +541,9 @@ def test_agent_create_blocks_when_preflight_invalid(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=preflight_block),
),
):
Expand Down Expand Up @@ -629,6 +640,9 @@ def test_ai_automation_create_succeeds_without_service_account(
"""
oauth_env("ai-create-public")
mock_client = MagicMock()
mock_client.validate_ai_automation_prompt = MethodType(
PipefyClient.validate_ai_automation_prompt, mock_client
)
# Prompt references field 9 as input; output field 88 is distinct so overlap preflight passes.
mock_client.get_pipe_with_preferences = AsyncMock(
return_value={
Expand Down Expand Up @@ -935,6 +949,9 @@ def test_ai_automation_update_auto_fetches_prompt_when_omitted(
# Prompt references field 9 as input; output field 88 is distinct so overlap preflight passes.
existing = _ai_automation_row("Summarize: %{9}", ["88"])
mock_client = MagicMock()
mock_client.validate_ai_automation_prompt = MethodType(
PipefyClient.validate_ai_automation_prompt, mock_client
)
mock_client.get_automation = AsyncMock(return_value=existing)
mock_client.get_pipe_with_preferences = AsyncMock(
return_value={
Expand Down
25 changes: 15 additions & 10 deletions packages/cli/tests/test_cli_agent_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=_PREFLIGHT_OK),
),
patch(
Expand Down Expand Up @@ -130,8 +131,9 @@ def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=_PREFLIGHT_OK),
),
patch(
Expand Down Expand Up @@ -193,8 +195,9 @@ def test_agent_update_json_exposes_active_when_disabled_at_null(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=_PREFLIGHT_OK),
),
patch(
Expand Down Expand Up @@ -250,8 +253,9 @@ def test_agent_update_json_exposes_active_false_when_disabled(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=_PREFLIGHT_OK),
),
patch(
Expand Down Expand Up @@ -307,8 +311,9 @@ def test_agent_update_passes_disabled_at_when_provided(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch(
"pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk",
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(return_value=_PREFLIGHT_OK),
),
patch(
Expand Down
4 changes: 1 addition & 3 deletions packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
collect_ai_behavior_move_transition_problems,
)
from pipefy_sdk.ai_pipe_validation import resolve_and_populate_field_refs
from pipefy_sdk.ai_preflight import validate_ai_agent_behaviors_sdk
from pydantic import ValidationError

from pipefy_mcp.tools.ai_tool_helpers import (
Expand Down Expand Up @@ -739,8 +738,7 @@ async def validate_ai_agent_behaviors(
if not pid:
return build_ai_tool_error("pipe_id must not be blank")

result = await validate_ai_agent_behaviors_sdk(
client,
result = await client.validate_ai_agent_behaviors(
pid,
behaviors,
strict_unknown_action_types=strict_unknown_action_types,
Expand Down
8 changes: 2 additions & 6 deletions packages/mcp/src/pipefy_mcp/tools/ai_automation_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
PipefyId,
UpdateAiAutomationInput,
)
from pipefy_sdk.ai_preflight import (
filter_ai_automation_summaries,
validate_ai_automation_prompt_sdk,
)
from pipefy_sdk.ai_preflight import filter_ai_automation_summaries
from pydantic import ValidationError

from pipefy_mcp.core.tool_error_envelope import tool_error_message
Expand Down Expand Up @@ -116,8 +113,7 @@ async def validate_ai_automation_prompt(
}
eid = eid_validated or None

result = await validate_ai_automation_prompt_sdk(
client,
result = await client.validate_ai_automation_prompt(
pid,
prompt,
[str(f) for f in field_ids],
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/tests/tools/test_ai_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import copy
from datetime import timedelta
from types import MethodType
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand All @@ -16,7 +17,7 @@
make_field_id,
make_pipe_id,
)
from pipefy_sdk import PipefyGraphQLError
from pipefy_sdk import PipefyClient, PipefyGraphQLError
from pipefy_sdk.models.ai_agent import CreateAiAgentInput, UpdateAiAgentInput

from pipefy_mcp.core.tool_error_envelope import tool_error_message
Expand All @@ -42,6 +43,9 @@ def mock_pipefy_client():
client.get_pipe_members = AsyncMock(return_value={"pipe": {"members": []}})
client.get_phase_allowed_move_targets = AsyncMock()
client.get_phase_fields = AsyncMock(return_value={"fields": []})
client.validate_ai_agent_behaviors = MethodType(
PipefyClient.validate_ai_agent_behaviors, client
)
return client


Expand Down
4 changes: 4 additions & 0 deletions packages/mcp/tests/tools/test_ai_automation_tools.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for AI Automation MCP tools."""

from datetime import timedelta
from types import MethodType
from unittest.mock import AsyncMock, MagicMock

import pytest
Expand Down Expand Up @@ -29,6 +30,9 @@ def mock_pipefy_client():
client.get_pipe_with_preferences = AsyncMock()
client.get_automation_events = AsyncMock()
client.get_ai_credit_usage = AsyncMock()
client.validate_ai_automation_prompt = MethodType(
PipefyClient.validate_ai_automation_prompt, client
)
return client


Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/src/pipefy_sdk/ai_phase_transition_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
from __future__ import annotations

import logging
from typing import Any
from typing import TYPE_CHECKING, Any

from pydantic import ValidationError

from pipefy_sdk.client import PipefyClient
from pipefy_sdk.models import BehaviorPayload
from pipefy_sdk.transition_hints import (
TRANSITION_RULES_HINT,
format_allowed_destinations_phrase,
)

if TYPE_CHECKING:
from pipefy_sdk.client import PipefyClient

logger = logging.getLogger(__name__)


Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/src/pipefy_sdk/ai_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import asyncio
import logging
import re
from typing import Any
from typing import TYPE_CHECKING, Any

from pydantic import ValidationError

Expand All @@ -20,9 +20,11 @@
validate_behaviors_against_pipe,
)
from pipefy_sdk.behavior_placeholders import expand_behaviors_placeholders
from pipefy_sdk.client import PipefyClient
from pipefy_sdk.models import BehaviorInput

if TYPE_CHECKING:
from pipefy_sdk.client import PipefyClient

logger = logging.getLogger(__name__)

_PROMPT_FIELD_TOKEN_RE = re.compile(r"%\{(\d+)\}")
Expand Down
Loading
Loading