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 @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- **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)

### Changed

- **SDK AI agent create**: `PipefyClient.create_ai_agent` now writes the agent's instruction and behaviors. It creates the agent and chains `update_ai_agent`, as the MCP tool and CLI command did on their own; before, it dropped the required `instruction` and `behaviors` and returned an empty, disabled agent. When the update fails, it raises the new `AiAgentConfigureError`, which carries the created `agent_uuid`. `CreateAiAgentInput` and `UpdateAiAgentInput` now expand `template_params` / `instruction_template` and normalize instruction token aliases while they validate, so SDK callers get the same prep as the MCP tools. As a result, a raw behavior dict with a literal `{{name}}` and no `template_params` now fails `CreateAiAgentInput` / `UpdateAiAgentInput` validation, as it already failed in the MCP tools. Callers that expanded behaviors themselves can drop that step: a second expansion fails when a substituted value contains `{{name}}`. The MCP tools and CLI commands call these methods. A CLI `agent create` whose update fails now prints the created agent's UUID. The unused `pipefy_mcp.tools.behavior_placeholder_interpolation` re-export is removed; import the helpers from `pipefy_sdk.behavior_placeholders`. (#695)

### 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
12 changes: 12 additions & 0 deletions docs/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The hierarchy:
- **`PipefyError`** — root of the API error types below.
- **`PipefyAPIError`** — the API returned an error payload.
- **`PipefyGraphQLError`** — a GraphQL response carried `errors`. Subclasses `PipefyAPIError`, and carries the raw list on `.errors`. This is what most failures arrive as.
- **`AiAgentConfigureError`** — `create_ai_agent` created the agent, but the update that writes its instruction and behaviors failed. The agent exists, and it is disabled when `.disabled_at` (the create's `disabledAt`) is set. To recover, write its behaviors with `update_ai_agent(agent_uuid, ...)`, which keeps it disabled, then activate it with `toggle_ai_agent_status`. To discard it, call `delete_ai_agent`. The error message says the same. The update's own error is `__cause__`.

Catch the specific type before the root, since `except PipefyError` also catches
`PipefyGraphQLError` and would otherwise shadow it.
Expand All @@ -48,6 +49,17 @@ 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.

## AI agents

`create_ai_agent(CreateAiAgentInput(...))` creates the agent and then calls `update_ai_agent` to write its `instruction`, `behaviors`, and `data_source_ids`. The API disables a new agent until an update with an active behavior clears `disabledAt`, so the chained update omits `disabledAt` unless you set `disabled_at` to create the agent inactive. If that update fails, the method raises `AiAgentConfigureError` (see [Errors](#errors)).

`CreateAiAgentInput` and `UpdateAiAgentInput` prepare raw behavior dicts while they validate, the same way as the MCP tools:

- `template_params` (or `placeholders`) fill `{{name}}` in every string of the behavior, and `instruction_template` becomes `actionParams.aiBehaviorParams.instruction`. A `{{name}}` without a value is a `ValidationError`.
- Instruction token aliases (`{field:X}`, `{action:<uuid>}`, `%{<digits>}`, `{<digits>}`) become `%{field:…}` / `%{action:…}`, on the agent `instruction` and on each behavior's.

The prep applies to raw dicts only. A `BehaviorInput` instance passes through as it is, because `BehaviorInput` itself does no prep: pass raw dicts, or build each `BehaviorInput` from the output of `pipefy_sdk.behavior_placeholders.expand_behavior_placeholders`.

## 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:
Expand Down
50 changes: 22 additions & 28 deletions packages/cli/src/pipefy_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,17 @@

import typer
from pipefy_sdk import (
AiAgentConfigureError,
CreateAiAgentInput,
PipefyClient,
PipefyGraphQLError,
UpdateAiAgentInput,
)
from pipefy_sdk.behavior_placeholders import (
expand_behaviors_placeholders,
normalize_pipefy_ai_instruction_tokens,
)
from pydantic import ValidationError

from pipefy_cli.commands._common import (
ID_POSITIONAL_CONTEXT_SETTINGS,
_format_transport_query_error,
confirm_destructive,
parse_json_value,
resource_id_argument,
Expand Down Expand Up @@ -143,15 +142,13 @@ def agent_create(
raise typer.BadParameter("--data-sources must be a JSON array of strings")
data_source_ids = list(ds_raw)

inst = normalize_pipefy_ai_instruction_tokens(instruction.strip())
disabled_at = None if active else datetime.now(timezone.utc).isoformat()
try:
expanded = expand_behaviors_placeholders(behavior_list)
validated = CreateAiAgentInput(
name=name.strip(),
repo_uuid=repo_uuid.strip(),
instruction=inst,
behaviors=expanded,
instruction=instruction.strip(),
behaviors=behavior_list,
data_source_ids=data_source_ids,
disabled_at=disabled_at,
)
Expand All @@ -165,26 +162,25 @@ async def factory(client: PipefyClient):
strict_unknown_action_types=strict_unknown,
)
_raise_if_preflight_blocks(pre)
create_result = await client.create_ai_agent(validated)
agent_uuid = create_result["agent_uuid"]
update_input = UpdateAiAgentInput(
uuid=agent_uuid,
name=validated.name,
repo_uuid=validated.repo_uuid,
instruction=validated.instruction,
behaviors=validated.behaviors,
data_source_ids=validated.data_source_ids,
disabled_at=validated.disabled_at,
preserve_disabled_at=False,
)
update_result = await client.update_ai_agent(update_input)
result_disabled_at = update_result.get("disabled_at")
try:
result = await client.create_ai_agent(validated)
except AiAgentConfigureError as exc:
cause = exc.__cause__
if not isinstance(cause, PipefyGraphQLError):
raise
# Keep the "message (CODE)" form the CLI prints for other GraphQL errors.
raise AiAgentConfigureError(
agent_uuid=exc.agent_uuid,
disabled_at=exc.disabled_at,
reason=_format_transport_query_error(cause),
) from cause
agent_uuid = result["agent_uuid"]
out: dict[str, Any] = {
"success": True,
"agent_uuid": agent_uuid,
"message": f"Created agent {agent_uuid}",
"disabled_at": result_disabled_at,
"active": update_result.get("active", result_disabled_at is None),
"disabled_at": result["disabled_at"],
"active": result["active"],
}
if pre.get("warnings"):
out["preflight"] = pre
Expand Down Expand Up @@ -249,15 +245,13 @@ def agent_update(
raise typer.BadParameter("--data-sources must be a JSON array of strings")
data_source_ids = list(ds_raw)

inst = normalize_pipefy_ai_instruction_tokens(instruction.strip())
try:
expanded = expand_behaviors_placeholders(behavior_list)
validated = UpdateAiAgentInput(
uuid=uuid.strip(),
name=name.strip(),
repo_uuid=repo_uuid.strip(),
instruction=inst,
behaviors=expanded,
instruction=instruction.strip(),
behaviors=behavior_list,
data_source_ids=data_source_ids,
disabled_at=disabled_at.strip() if disabled_at else None,
)
Expand Down
68 changes: 62 additions & 6 deletions packages/cli/tests/test_cli_agent_automation_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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

from pipefy_cli.main import app
Expand Down Expand Up @@ -378,11 +378,12 @@ def test_agent_create_happy_path_chains_create_then_update(
"""``agent create`` runs preflight, then ``create_ai_agent`` + ``update_ai_agent``."""
oauth_env("ag-create-ok")
mock_client = MagicMock()
mock_client.create_ai_agent = AsyncMock(
return_value={"agent_uuid": "uuid-1", "disabled_at": None}
mock_client.create_ai_agent = MethodType(PipefyClient.create_ai_agent, mock_client)
mock_client._ai_agent_service.create_agent = AsyncMock(
return_value={"agent_uuid": "uuid-1", "disabled_at": "2026-08-04T12:00:00Z"}
)
mock_client.update_ai_agent = AsyncMock(
return_value={"agent_uuid": "uuid-1", "disabled_at": None}
return_value={"agent_uuid": "uuid-1", "disabled_at": None, "active": True}
)

preflight_ok = {
Expand Down Expand Up @@ -436,14 +437,69 @@ def test_agent_create_happy_path_chains_create_then_update(
"disabled_at": None,
"active": True,
}
mock_client.create_ai_agent.assert_awaited_once()
create_arg = mock_client.create_ai_agent.call_args.args[0]
mock_client._ai_agent_service.create_agent.assert_awaited_once()
create_arg = mock_client._ai_agent_service.create_agent.call_args.args[0]
assert create_arg.disabled_at is None
mock_client.update_ai_agent.assert_awaited_once()
update_arg = mock_client.update_ai_agent.call_args.args[0]
assert update_arg.disabled_at is None


def test_agent_create_update_failure_prints_created_uuid_and_error_code(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
"""A failed configure update names the created agent and keeps the GraphQL code."""
oauth_env("ag-create-partial")
mock_client = MagicMock()
mock_client.create_ai_agent = MethodType(PipefyClient.create_ai_agent, mock_client)
mock_client._ai_agent_service.create_agent = AsyncMock(
return_value={"agent_uuid": "uuid-1", "disabled_at": "2026-08-04T12:00:00Z"}
)
mock_client.update_ai_agent = AsyncMock(
side_effect=PipefyGraphQLError(
[{"message": "Invalid", "extensions": {"code": "RECORD_NOT_SAVED"}}]
)
)

with (
patch(
"pipefy_cli.commands._common.get_authenticated_client",
return_value=mock_client,
),
patch.object(
mock_client,
"validate_ai_agent_behaviors",
new=AsyncMock(
return_value={"success": True, "valid": True, "problems": []}
),
),
):
r = runner.invoke(
app,
[
"agent",
"create",
"--repo-uuid",
"repo-uuid-1",
"--pipe",
"1",
"--name",
"Acme",
"--instruction",
"Be helpful.",
"--behaviors",
json.dumps([_AGENT_BEHAVIOR]),
],
)

assert r.exit_code == 1
stderr = " ".join(r.stderr.split())
assert "uuid-1" in stderr
assert "Invalid (RECORD_NOT_SAVED)" in stderr
assert "is disabled" in stderr
assert "toggle_ai_agent_status" in stderr


def test_agent_update_invokes_field_ref_resolution_via_facade(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
Expand Down
30 changes: 6 additions & 24 deletions packages/cli/tests/test_cli_agent_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,16 @@
}


def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain(
def test_agent_create_default_sends_no_disabled_at(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
"""Default ``agent create`` passes ``preserve_disabled_at=False`` on chained update."""
"""Default ``agent create`` builds an input without ``disabled_at`` (the SDK chain activates it)."""
oauth_env("ag-create-active")
mock_client = MagicMock()
mock_client.create_ai_agent = AsyncMock(
return_value={
"agent_uuid": "active-uuid",
"disabled_at": "2026-08-04T12:00:00+00:00",
"active": False,
}
)
mock_client.update_ai_agent = AsyncMock(
return_value={
"agent_uuid": "active-uuid",
"message": "AI Agent created and configured successfully. UUID: active-uuid",
"disabled_at": None,
"active": True,
}
Expand Down Expand Up @@ -99,28 +93,19 @@ def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain(

create_arg = mock_client.create_ai_agent.call_args.args[0]
assert create_arg.disabled_at is None
update_arg = mock_client.update_ai_agent.call_args.args[0]
assert update_arg.disabled_at is None
assert update_arg.preserve_disabled_at is False


def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain(
def test_agent_create_inactive_sets_disabled_at_on_the_create_input(
runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env
):
"""``agent create --inactive`` sets the same ``disabled_at`` on create + chained update."""
"""``agent create --inactive`` sets an ISO ``disabled_at`` on the create input."""
oauth_env("ag-create-inactive")
stub_disabled_at = "2026-08-04T13:00:00+00:00"
mock_client = MagicMock()
mock_client.create_ai_agent = AsyncMock(
return_value={
"agent_uuid": "inactive-uuid",
"disabled_at": stub_disabled_at,
"active": False,
}
)
mock_client.update_ai_agent = AsyncMock(
return_value={
"agent_uuid": "inactive-uuid",
"message": "AI Agent created and configured successfully. UUID: inactive-uuid",
"disabled_at": stub_disabled_at,
"active": False,
}
Expand Down Expand Up @@ -170,9 +155,6 @@ def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain(
create_arg = mock_client.create_ai_agent.call_args.args[0]
assert create_arg.disabled_at is not None
datetime.fromisoformat(create_arg.disabled_at)
update_arg = mock_client.update_ai_agent.call_args.args[0]
assert update_arg.disabled_at == create_arg.disabled_at
assert update_arg.preserve_disabled_at is False


def test_agent_update_json_exposes_active_when_disabled_at_null(
Expand Down
Loading
Loading