From 3f2044a71f5efde3bea85308fded2d89ad6ca02b Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:28:18 -0300 Subject: [PATCH 1/5] feat(sdk): create_ai_agent writes behaviors, and agent inputs prepare them PipefyClient.create_ai_agent sent only name, repoUuid and disabledAt, so it dropped the required instruction and behaviors of CreateAiAgentInput and returned an empty, disabled agent. Only the MCP tool and the CLI chained the update that writes them. The client now creates the agent and chains update_ai_agent with preserve_disabled_at=False. The API stamps disabledAt on a new agent and only an update with an active behavior clears it, so the chain is needed. When the update fails, the new AiAgentConfigureError carries the created agent_uuid and chains the update's error. CreateAiAgentInput and UpdateAiAgentInput expand template_params / instruction_template and normalize instruction token aliases on raw behavior dicts while they validate. The client cannot do this, because BehaviorInput validation already drops the template keys. BehaviorInput instances pass through, since expansion is not idempotent. The MCP tools and CLI commands drop their own prep and chain. The unused pipefy_mcp.tools.behavior_placeholder_interpolation re-export is removed, and its tests move to the SDK next to the helpers they cover. Closes #695 Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- CHANGELOG.md | 4 + docs/sdk/README.md | 12 ++ packages/cli/src/pipefy_cli/commands/agent.py | 50 +++---- .../tests/test_cli_agent_automation_smoke.py | 65 ++++++++- .../cli/tests/test_cli_agent_lifecycle.py | 12 +- .../src/pipefy_mcp/tools/ai_agent_tools.py | 113 +++++---------- .../behavior_placeholder_interpolation.py | 23 --- .../tests/tools/test_ai_agent_lifecycle.py | 13 +- .../mcp/tests/tools/test_ai_agent_tools.py | 89 +++++++----- packages/sdk/src/pipefy_sdk/__init__.py | 3 +- packages/sdk/src/pipefy_sdk/client.py | 48 +++++-- packages/sdk/src/pipefy_sdk/exceptions.py | 18 +++ .../sdk/src/pipefy_sdk/models/ai_agent.py | 66 +++++++-- packages/sdk/tests/models/test_ai_agent.py | 53 +++++++ .../sdk/tests/services/test_pipefy_facade.py | 134 +++++++++++++++--- .../tests/test_behavior_placeholders.py} | 2 +- skills/ai-agents/pipefy-ai-agents/SKILL.md | 4 +- 17 files changed, 486 insertions(+), 223 deletions(-) delete mode 100644 packages/mcp/src/pipefy_mcp/tools/behavior_placeholder_interpolation.py rename packages/{mcp/tests/tools/test_behavior_placeholder_interpolation.py => sdk/tests/test_behavior_placeholders.py} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2819fa945..5e6bd3976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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) diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 00ef7f6d6..e1156e116 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -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: `.agent_uuid` identifies it for a recovery `update_ai_agent` or a `delete_ai_agent`, and `.disabled_at` is the create's `disabledAt`. 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. @@ -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:}`, `%{}`, `{}`) 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: diff --git a/packages/cli/src/pipefy_cli/commands/agent.py b/packages/cli/src/pipefy_cli/commands/agent.py index b1c8defd0..8c993a961 100644 --- a/packages/cli/src/pipefy_cli/commands/agent.py +++ b/packages/cli/src/pipefy_cli/commands/agent.py @@ -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, @@ -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, ) @@ -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 @@ -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, ) diff --git a/packages/cli/tests/test_cli_agent_automation_smoke.py b/packages/cli/tests/test_cli_agent_automation_smoke.py index 5de33f9f5..1c0542ae7 100644 --- a/packages/cli/tests/test_cli_agent_automation_smoke.py +++ b/packages/cli/tests/test_cli_agent_automation_smoke.py @@ -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 @@ -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 = { @@ -436,14 +437,66 @@ 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 + assert "uuid-1" in r.stderr + assert "Invalid (RECORD_NOT_SAVED)" in r.stderr + + def test_agent_update_invokes_field_ref_resolution_via_facade( runner: CliRunner, clean_pipefy_env, saved_cwd, oauth_env ): diff --git a/packages/cli/tests/test_cli_agent_lifecycle.py b/packages/cli/tests/test_cli_agent_lifecycle.py index 132ba8bd8..74ce98e07 100644 --- a/packages/cli/tests/test_cli_agent_lifecycle.py +++ b/packages/cli/tests/test_cli_agent_lifecycle.py @@ -4,8 +4,10 @@ import json from datetime import datetime +from types import MethodType from unittest.mock import AsyncMock, MagicMock, patch +from pipefy_sdk import PipefyClient from typer.testing import CliRunner from pipefy_cli.main import app @@ -42,7 +44,8 @@ def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain( """Default ``agent create`` passes ``preserve_disabled_at=False`` on chained update.""" oauth_env("ag-create-active") mock_client = MagicMock() - mock_client.create_ai_agent = AsyncMock( + mock_client.create_ai_agent = MethodType(PipefyClient.create_ai_agent, mock_client) + mock_client._ai_agent_service.create_agent = AsyncMock( return_value={ "agent_uuid": "active-uuid", "disabled_at": "2026-08-04T12:00:00+00:00", @@ -97,7 +100,7 @@ def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain( assert body["disabled_at"] is None assert body["active"] is True - create_arg = mock_client.create_ai_agent.call_args.args[0] + create_arg = mock_client._ai_agent_service.create_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 @@ -111,7 +114,8 @@ def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain( oauth_env("ag-create-inactive") stub_disabled_at = "2026-08-04T13:00:00+00:00" mock_client = MagicMock() - mock_client.create_ai_agent = AsyncMock( + mock_client.create_ai_agent = MethodType(PipefyClient.create_ai_agent, mock_client) + mock_client._ai_agent_service.create_agent = AsyncMock( return_value={ "agent_uuid": "inactive-uuid", "disabled_at": stub_disabled_at, @@ -167,7 +171,7 @@ def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain( assert body["disabled_at"] == stub_disabled_at assert body["active"] is False - create_arg = mock_client.create_ai_agent.call_args.args[0] + create_arg = mock_client._ai_agent_service.create_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] 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 51d2739f6..6f63a028a 100644 --- a/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py @@ -7,6 +7,8 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.types import ToolAnnotations from pipefy_sdk import ( + AiAgentConfigureError, + BehaviorInput, BehaviorPayload, CreateAiAgentInput, PipefyClient, @@ -33,10 +35,6 @@ fetch_pipe_validation_context, validate_behaviors_against_pipe, ) -from pipefy_mcp.tools.behavior_placeholder_interpolation import ( - expand_behaviors_placeholders, - normalize_pipefy_ai_instruction_tokens, -) from pipefy_mcp.tools.destructive_tool_guard import check_destructive_confirmation from pipefy_mcp.tools.graphql_error_helpers import ( enrich_permission_denied_error, @@ -154,6 +152,20 @@ async def _enrich_with_validation( except Exception: # noqa: BLE001 return enriched + async def _describe_update_error( + exc: BaseException, behaviors: list[BehaviorInput], client: PipefyClient + ) -> str: + """Error text for a failed ``updateAiAgent``, with permission and validation hints.""" + raw = [b.model_dump(by_alias=True) for b in behaviors] + try: + resolved = await resolve_and_populate_field_refs(client, raw) + except Exception: # noqa: BLE001 + resolved = raw + pipe_ids = collect_pipe_ids_from_behaviors(resolved) + perm_msg = await enrich_permission_denied_error(exc, pipe_ids, client) + error_text = await _enrich_with_validation(exc, resolved, client) + return f"{perm_msg}\n{error_text}" if perm_msg else error_text + @mcp.tool( annotations=ToolAnnotations(readOnlyHint=False), meta=REMOTE, @@ -313,18 +325,13 @@ async def create_ai_agent( return build_ai_tool_error("repo_uuid must not be blank") if not instruction or not instruction.strip(): return build_ai_tool_error("instruction must not be blank") - instruction = normalize_pipefy_ai_instruction_tokens(instruction) - try: - behaviors_expanded = expand_behaviors_placeholders(behaviors) - except ValueError as exc: - return build_ai_tool_error(str(exc)) disabled_at = None if active else datetime.now(timezone.utc).isoformat() try: validated = CreateAiAgentInput( name=name, repo_uuid=repo_uuid, instruction=instruction, - behaviors=behaviors_expanded, + behaviors=behaviors, data_source_ids=data_source_ids or [], disabled_at=disabled_at, ) @@ -332,55 +339,31 @@ async def create_ai_agent( return build_ai_tool_error(str(exc)) try: - create_result = await client.create_ai_agent(validated) + result = await client.create_ai_agent(validated) + except AiAgentConfigureError as exc: + return build_create_agent_partial_failure( + agent_uuid=exc.agent_uuid, + error=await _describe_update_error( + exc.__cause__ or exc, validated.behaviors, client + ), + disabled_at=exc.disabled_at, + ) except Exception as exc: # noqa: BLE001 - pipe_ids = collect_pipe_ids_from_behaviors(behaviors_expanded) + behavior_dicts = [ + b.model_dump(by_alias=True, exclude_none=True) + for b in validated.behaviors + ] + pipe_ids = collect_pipe_ids_from_behaviors(behavior_dicts) perm_msg = await enrich_permission_denied_error(exc, pipe_ids, client) - error_text = enrich_behavior_error(exc, behaviors_expanded) + error_text = enrich_behavior_error(exc, behavior_dicts) if perm_msg: error_text = f"{perm_msg}\n{error_text}" return build_ai_tool_error(error_text) - 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, - ) - try: - update_result = await client.update_ai_agent(update_input) - except Exception as exc: # noqa: BLE001 - try: - resolved = await resolve_and_populate_field_refs( - client, - [b.model_dump(by_alias=True) for b in update_input.behaviors], - ) - except Exception: # noqa: BLE001 - resolved = [ - b.model_dump(by_alias=True) for b in update_input.behaviors - ] - pipe_ids = collect_pipe_ids_from_behaviors(resolved) - perm_msg = await enrich_permission_denied_error(exc, pipe_ids, client) - error_text = await _enrich_with_validation(exc, resolved, client) - if perm_msg: - error_text = f"{perm_msg}\n{error_text}" - return build_create_agent_partial_failure( - agent_uuid=agent_uuid, - error=error_text, - disabled_at=create_result.get("disabled_at"), - ) - - msg = f"AI Agent created and configured successfully. UUID: {agent_uuid}" return build_create_agent_success( - agent_uuid=agent_uuid, - message=msg, - disabled_at=update_result.get("disabled_at"), + agent_uuid=result["agent_uuid"], + message=result["message"], + disabled_at=result.get("disabled_at"), ) @mcp.tool( @@ -472,19 +455,13 @@ async def update_ai_agent( return build_ai_tool_error("name must not be blank") if not repo_uuid or not repo_uuid.strip(): return build_ai_tool_error("repo_uuid must not be blank") - if instruction: - instruction = normalize_pipefy_ai_instruction_tokens(instruction) - try: - behaviors_expanded = expand_behaviors_placeholders(behaviors) - except ValueError as exc: - return build_ai_tool_error(str(exc)) try: validated = UpdateAiAgentInput( uuid=uuid, name=name, repo_uuid=repo_uuid, instruction=instruction, - behaviors=behaviors_expanded, + behaviors=behaviors, data_source_ids=data_source_ids or [], disabled_at=disabled_at, ) @@ -494,21 +471,9 @@ async def update_ai_agent( try: result = await client.update_ai_agent(validated) except Exception as exc: # noqa: BLE001 - try: - resolved = await resolve_and_populate_field_refs( - client, - [b.model_dump(by_alias=True) for b in validated.behaviors], - ) - except Exception: # noqa: BLE001 - resolved = [ - b.model_dump(by_alias=True) for b in validated.behaviors - ] - pipe_ids = collect_pipe_ids_from_behaviors(resolved) - perm_msg = await enrich_permission_denied_error(exc, pipe_ids, client) - error_text = await _enrich_with_validation(exc, resolved, client) - if perm_msg: - error_text = f"{perm_msg}\n{error_text}" - return build_ai_tool_error(error_text) + return build_ai_tool_error( + await _describe_update_error(exc, validated.behaviors, client) + ) return build_update_agent_success( agent_uuid=result["agent_uuid"], diff --git a/packages/mcp/src/pipefy_mcp/tools/behavior_placeholder_interpolation.py b/packages/mcp/src/pipefy_mcp/tools/behavior_placeholder_interpolation.py deleted file mode 100644 index 9117fc5c8..000000000 --- a/packages/mcp/src/pipefy_mcp/tools/behavior_placeholder_interpolation.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Re-export behavior placeholder helpers from ``pipefy_sdk`` (single source of truth). - -New code should import from ``pipefy_sdk.behavior_placeholders``; this module remains -for backward compatibility with existing ``pipefy_mcp.tools`` imports. -""" - -from __future__ import annotations - -from pipefy_sdk.behavior_placeholders import ( - expand_behavior_placeholders, - expand_behaviors_placeholders, - extract_referenced_field_ids, - normalize_pipefy_ai_instruction_tokens, - populate_referenced_field_ids, -) - -__all__ = [ - "expand_behavior_placeholders", - "expand_behaviors_placeholders", - "extract_referenced_field_ids", - "normalize_pipefy_ai_instruction_tokens", - "populate_referenced_field_ids", -] diff --git a/packages/mcp/tests/tools/test_ai_agent_lifecycle.py b/packages/mcp/tests/tools/test_ai_agent_lifecycle.py index 1872343f3..0641ace65 100644 --- a/packages/mcp/tests/tools/test_ai_agent_lifecycle.py +++ b/packages/mcp/tests/tools/test_ai_agent_lifecycle.py @@ -1,6 +1,7 @@ """Lifecycle tests for AI agent active/disabled create and update.""" from datetime import datetime, timedelta +from types import MethodType from unittest.mock import AsyncMock, MagicMock import pytest @@ -8,6 +9,7 @@ create_connected_server_and_client_session as create_client_session, ) from _shared.ai_agent_test_payloads import minimal_behavior_dict +from pipefy_sdk import PipefyClient from pipefy_sdk.models.ai_agent import CreateAiAgentInput, UpdateAiAgentInput from pipefy_mcp.tools.ai_agent_tools import AiAgentTools @@ -17,7 +19,8 @@ @pytest.fixture def mock_pipefy_client(): client = MagicMock() - client.create_ai_agent = AsyncMock() + client.create_ai_agent = MethodType(PipefyClient.create_ai_agent, client) + client._ai_agent_service.create_agent = AsyncMock() client.update_ai_agent = AsyncMock() client.toggle_ai_agent_status = AsyncMock() client.get_ai_agent = AsyncMock() @@ -57,7 +60,7 @@ async def test_create_active_sets_preserve_disabled_at_false_on_update_chain( envelope_flag, ): """Default/active create omits preserve so configure update can clear API default.""" - mock_pipefy_client.create_ai_agent.return_value = { + mock_pipefy_client._ai_agent_service.create_agent.return_value = { "agent_uuid": "active-uuid", "message": "created", "disabled_at": "2026-08-04T12:00:00+00:00", @@ -80,7 +83,7 @@ async def test_create_active_sets_preserve_disabled_at_false_on_update_chain( }, ) assert result.is_error is False - create_arg = mock_pipefy_client.create_ai_agent.call_args[0][0] + create_arg = mock_pipefy_client._ai_agent_service.create_agent.call_args[0][0] assert isinstance(create_arg, CreateAiAgentInput) assert create_arg.disabled_at is None update_arg = mock_pipefy_client.update_ai_agent.call_args[0][0] @@ -106,7 +109,7 @@ async def test_create_inactive_sets_disabled_at_on_create_and_update_chain( envelope_flag, ): stub_disabled_at = "2026-08-04T13:00:00+00:00" - mock_pipefy_client.create_ai_agent.return_value = { + mock_pipefy_client._ai_agent_service.create_agent.return_value = { "agent_uuid": "inactive-uuid", "message": "created", "disabled_at": stub_disabled_at, @@ -130,7 +133,7 @@ async def test_create_inactive_sets_disabled_at_on_create_and_update_chain( }, ) assert result.is_error is False - create_arg = mock_pipefy_client.create_ai_agent.call_args[0][0] + create_arg = mock_pipefy_client._ai_agent_service.create_agent.call_args[0][0] assert isinstance(create_arg, CreateAiAgentInput) assert create_arg.disabled_at is not None datetime.fromisoformat(create_arg.disabled_at) diff --git a/packages/mcp/tests/tools/test_ai_agent_tools.py b/packages/mcp/tests/tools/test_ai_agent_tools.py index fc85cb6aa..7275e0648 100644 --- a/packages/mcp/tests/tools/test_ai_agent_tools.py +++ b/packages/mcp/tests/tools/test_ai_agent_tools.py @@ -17,7 +17,7 @@ make_field_id, make_pipe_id, ) -from pipefy_sdk import PipefyClient, PipefyGraphQLError +from pipefy_sdk import AiAgentConfigureError, PipefyClient, PipefyGraphQLError from pipefy_sdk.models.ai_agent import CreateAiAgentInput, UpdateAiAgentInput from pipefy_mcp.core.tool_error_envelope import tool_error_message @@ -75,13 +75,9 @@ async def test_data_source_ids_defaults_to_empty_list( ): mock_pipefy_client.create_ai_agent.return_value = { "agent_uuid": "abc-123", - "message": "created", - "disabled_at": None, - } - mock_pipefy_client.update_ai_agent.return_value = { - "agent_uuid": "abc-123", - "message": "updated", + "message": "created and configured", "disabled_at": None, + "active": True, } async with client_session as session: result = await session.call_tool( @@ -96,11 +92,9 @@ async def test_data_source_ids_defaults_to_empty_list( assert result.is_error is False payload = extract_payload(result) assert payload["success"] is True - update_arg = mock_pipefy_client.update_ai_agent.call_args[0][0] - assert isinstance(update_arg, UpdateAiAgentInput) - assert update_arg.data_source_ids == [] create_arg = mock_pipefy_client.create_ai_agent.call_args[0][0] assert isinstance(create_arg, CreateAiAgentInput) + assert create_arg.data_source_ids == [] assert create_arg.disabled_at is None async def test_service_error_returns_error_payload( @@ -212,13 +206,9 @@ async def test_create_and_configure_success( ): mock_pipefy_client.create_ai_agent.return_value = { "agent_uuid": "new-uuid", - "message": "created", - "disabled_at": None, - } - mock_pipefy_client.update_ai_agent.return_value = { - "agent_uuid": "new-uuid", - "message": "updated", + "message": "AI Agent created and configured successfully. UUID: new-uuid", "disabled_at": None, + "active": True, } behaviors = [minimal_behavior_dict(name="B1")] async with client_session as session: @@ -234,17 +224,16 @@ async def test_create_and_configure_success( ) assert result.is_error is False mock_pipefy_client.create_ai_agent.assert_awaited_once() - mock_pipefy_client.update_ai_agent.assert_awaited_once() - update_arg = mock_pipefy_client.update_ai_agent.call_args[0][0] - assert isinstance(update_arg, UpdateAiAgentInput) - assert update_arg.uuid == "new-uuid" - assert update_arg.name == "Configured Agent" - assert update_arg.repo_uuid == "repo-789" - assert update_arg.instruction == "Tell users about the pipe" - assert len(update_arg.behaviors) == 1 - assert update_arg.behaviors[0].name == "B1" - assert update_arg.behaviors[0].event_id == "card_created" - assert update_arg.data_source_ids == ["ds-1", "ds-2"] + mock_pipefy_client.update_ai_agent.assert_not_called() + create_arg = mock_pipefy_client.create_ai_agent.call_args[0][0] + assert isinstance(create_arg, CreateAiAgentInput) + assert create_arg.name == "Configured Agent" + assert create_arg.repo_uuid == "repo-789" + assert create_arg.instruction == "Tell users about the pipe" + assert len(create_arg.behaviors) == 1 + assert create_arg.behaviors[0].name == "B1" + assert create_arg.behaviors[0].event_id == "card_created" + assert create_arg.data_source_ids == ["ds-1", "ds-2"] payload = extract_payload(result) assert payload["success"] is True if envelope_flag: @@ -263,13 +252,13 @@ async def test_partial_failure_returns_uuid_and_error( extract_payload, ): stub_disabled_at = "2026-08-04T12:00:00+00:00" - mock_pipefy_client.create_ai_agent.return_value = { - "agent_uuid": "created-uuid", - "message": "AI Agent created successfully. UUID: created-uuid", - "disabled_at": stub_disabled_at, - "active": False, - } - mock_pipefy_client.update_ai_agent.side_effect = ValueError("update failed") + configure_error = AiAgentConfigureError( + agent_uuid="created-uuid", + disabled_at=stub_disabled_at, + reason="sdk summary", + ) + configure_error.__cause__ = ValueError("update failed") + mock_pipefy_client.create_ai_agent.side_effect = configure_error async with client_session as session: result = await session.call_tool( "create_ai_agent", @@ -289,9 +278,41 @@ async def test_partial_failure_returns_uuid_and_error( assert "error" in payload err_msg = tool_error_message(payload) assert "update failed" in err_msg + assert "sdk summary" not in err_msg assert "toggle_ai_agent_status" in err_msg assert "disabled" in err_msg.lower() + async def test_create_expands_template_params_before_the_sdk_call( + self, + client_session, + mock_pipefy_client, + extract_payload, + ): + mock_pipefy_client.create_ai_agent.return_value = { + "agent_uuid": "new-uuid", + "message": "created and configured", + "disabled_at": None, + "active": True, + } + behavior = minimal_behavior_dict(name="B1") + behavior["template_params"] = {"field": "123"} + behavior["instruction_template"] = "Read %{field:{{field}}}." + async with client_session as session: + result = await session.call_tool( + "create_ai_agent", + { + "name": "My Agent", + "repo_uuid": "repo-456", + "instruction": "Use {field:9}.", + "behaviors": [behavior], + }, + ) + assert extract_payload(result)["success"] is True + create_arg = mock_pipefy_client.create_ai_agent.call_args[0][0] + assert create_arg.instruction == "Use %{field:9}." + abp = create_arg.behaviors[0].action_params.ai_behavior_params + assert abp.instruction == "Read %{field:123}." + async def test_update_passes_disabled_at_when_provided( self, client_session, diff --git a/packages/sdk/src/pipefy_sdk/__init__.py b/packages/sdk/src/pipefy_sdk/__init__.py index 3413d8e01..95f612d7f 100644 --- a/packages/sdk/src/pipefy_sdk/__init__.py +++ b/packages/sdk/src/pipefy_sdk/__init__.py @@ -5,7 +5,7 @@ __version__ = "0.5.2-beta.1" from pipefy_sdk.client import PipefyClient, PipefyEngine -from pipefy_sdk.exceptions import PipefyAPIError, PipefyError +from pipefy_sdk.exceptions import AiAgentConfigureError, PipefyAPIError, PipefyError from pipefy_sdk.field_filters import ( filter_editable_field_definitions, filter_fields_by_definitions, @@ -98,6 +98,7 @@ __all__ = [ "__version__", + "AiAgentConfigureError", "AiAgentGraphPayload", "AUTOMATION_EVENT_IDS", "AUTOMATION_EXECUTION_METRICS_MAX_PAGE_SIZE", diff --git a/packages/sdk/src/pipefy_sdk/client.py b/packages/sdk/src/pipefy_sdk/client.py index eb8cef8ff..07f348324 100644 --- a/packages/sdk/src/pipefy_sdk/client.py +++ b/packages/sdk/src/pipefy_sdk/client.py @@ -19,6 +19,7 @@ validate_automation_field_map_field_ids, validate_traditional_automation_move_transition, ) +from pipefy_sdk.exceptions import AiAgentConfigureError from pipefy_sdk.graphql_executor import ( AuthenticatedExecutor, GraphQLEndpoint, @@ -1393,13 +1394,42 @@ async def validate_knowledge_base_access( async def create_ai_agent( self, agent_input: CreateAiAgentInput ) -> AgentServiceResult: - """Create an AI Agent (empty, no behaviors). + """Create an AI Agent and write its instruction and behaviors. - Callers are still responsible for pre-Pydantic prep (``normalize_pipefy_ai_instruction_tokens`` - / ``expand_behaviors_placeholders``) where applicable because those run before - :class:`CreateAiAgentInput` validation at the tool/CLI boundary. - """ - return await self._ai_agent_service.create_agent(agent_input) + Runs ``createAiAgent`` and then :meth:`update_ai_agent`. The API stamps + ``disabledAt`` on a new agent, and only an update with an active behavior + clears it, so the update omits ``disabledAt`` unless ``agent_input.disabled_at`` + is set. Placeholder and token prep happen when :class:`CreateAiAgentInput` + validates. + + Raises: + AiAgentConfigureError: The agent was created but the update failed. It + carries ``agent_uuid`` for recovery; the update's error is ``__cause__``. + """ + created = await self._ai_agent_service.create_agent(agent_input) + agent_uuid = created["agent_uuid"] + update_input = UpdateAiAgentInput( + uuid=agent_uuid, + name=agent_input.name, + repo_uuid=agent_input.repo_uuid, + instruction=agent_input.instruction, + behaviors=agent_input.behaviors, + data_source_ids=agent_input.data_source_ids, + disabled_at=agent_input.disabled_at, + preserve_disabled_at=False, + ) + try: + updated = await self.update_ai_agent(update_input) + except Exception as exc: + raise AiAgentConfigureError( + agent_uuid=agent_uuid, + disabled_at=created["disabled_at"], + reason=str(exc), + ) from exc + return { + **updated, + "message": f"AI Agent created and configured successfully. UUID: {agent_uuid}", + } async def update_ai_agent( self, agent_input: UpdateAiAgentInput @@ -1408,10 +1438,8 @@ async def update_ai_agent( Resolves field-slug references inside behaviors to numeric IDs and populates ``referencedFieldIds`` before calling the service, so - callers do not need to remember the prep step. Callers are still - responsible for pre-Pydantic prep (``normalize_pipefy_ai_instruction_tokens`` - / ``expand_behaviors_placeholders``) because those run before - :class:`UpdateAiAgentInput` validation. + callers do not need to remember the prep step. Placeholder and token + prep happen when :class:`UpdateAiAgentInput` validates. """ raw_behaviors = [b.model_dump(by_alias=True) for b in agent_input.behaviors] resolved_dicts = await resolve_and_populate_field_refs(self, raw_behaviors) diff --git a/packages/sdk/src/pipefy_sdk/exceptions.py b/packages/sdk/src/pipefy_sdk/exceptions.py index 0b7a0f11d..27c1f2019 100644 --- a/packages/sdk/src/pipefy_sdk/exceptions.py +++ b/packages/sdk/src/pipefy_sdk/exceptions.py @@ -11,5 +11,23 @@ class PipefyAPIError(PipefyError): """Raised when the Pipefy GraphQL API returns an error payload.""" +class AiAgentConfigureError(PipefyError): + """Raised when ``create_ai_agent`` created the agent but its configure update failed. + + The agent exists: recover with ``update_ai_agent(agent_uuid, ...)`` or remove it + with ``delete_ai_agent``. The update's error is the ``__cause__``. + """ + + def __init__( + self, *, agent_uuid: str, disabled_at: str | None, reason: str + ) -> None: + self.agent_uuid = agent_uuid + self.disabled_at = disabled_at + super().__init__( + f"AI Agent {agent_uuid} was created, but writing its instruction " + f"and behaviors failed: {reason}" + ) + + class PortalPermissionError(ValueError): """Raised when a portal Interfaces operation fails with PERMISSION_DENIED.""" diff --git a/packages/sdk/src/pipefy_sdk/models/ai_agent.py b/packages/sdk/src/pipefy_sdk/models/ai_agent.py index 59a9561d4..fad500ed3 100644 --- a/packages/sdk/src/pipefy_sdk/models/ai_agent.py +++ b/packages/sdk/src/pipefy_sdk/models/ai_agent.py @@ -404,17 +404,58 @@ def ai_behavior_must_include_at_least_one_action(self) -> Self: return self -class CreateAiAgentInput(BaseModel): - """Validated input for create-and-configure: name/repo_uuid plus optional disabled_at for inactive create.""" +def _normalize_agent_instruction(value: object) -> object: + """Rewrite instruction token aliases to the canonical ``%{field:…}`` / ``%{action:…}`` form.""" + # Deferred: behavior_placeholders imports pipefy_sdk.models. + from pipefy_sdk.behavior_placeholders import normalize_pipefy_ai_instruction_tokens + + return ( + normalize_pipefy_ai_instruction_tokens(value) + if isinstance(value, str) + else value + ) - name: NonBlankStr - repo_uuid: NonBlankStr - instruction: NonBlankStr - behaviors: list[BehaviorInput] = Field( + +def _expand_raw_behaviors(value: object) -> object: + """Expand ``{{placeholders}}`` and normalize tokens in raw behavior dicts. + + ``BehaviorInput`` instances pass through as they are: expansion is not + idempotent, so a behavior that was already validated is never expanded again. + """ + from pipefy_sdk.behavior_placeholders import expand_behavior_placeholders + + if not isinstance(value, list): + return value + return [ + expand_behavior_placeholders(b) if isinstance(b, dict) else b for b in value + ] + + +_AgentInstruction = Annotated[str, BeforeValidator(_normalize_agent_instruction)] +_AgentBehaviors = Annotated[ + list[BehaviorInput], + BeforeValidator(_expand_raw_behaviors), + Field( min_length=1, max_length=MAX_BEHAVIORS, description="List of behaviors (1 to MAX_BEHAVIORS)", - ) + ), +] + + +class CreateAiAgentInput(BaseModel): + """Validated input for create-and-configure (``PipefyClient.create_ai_agent``). + + Raw behavior dicts (not ``BehaviorInput`` instances) get the same prep as the + MCP tools: ``template_params`` / ``placeholders`` and ``instruction_template`` + are expanded, and instruction token aliases are normalized, on the agent + ``instruction`` and on each behavior's. ``disabled_at`` creates the agent inactive. + """ + + name: NonBlankStr + repo_uuid: NonBlankStr + instruction: Annotated[NonBlankStr, BeforeValidator(_normalize_agent_instruction)] + behaviors: _AgentBehaviors data_source_ids: list[str] = Field(default_factory=list) disabled_at: NonBlankStr | None = None @@ -422,6 +463,9 @@ class CreateAiAgentInput(BaseModel): class UpdateAiAgentInput(BaseModel): """Validated input for updating an AI Agent. + Raw behavior dicts and ``instruction`` get the same prep as on + :class:`CreateAiAgentInput`. + Prefer passing ``disabled_at`` from a prior ``get_ai_agent`` read (pass-through; skips the preserve re-read). When ``preserve_disabled_at`` is True (default) and ``disabled_at`` is None, the adapter fetches the current agent and re-sends @@ -433,12 +477,8 @@ class UpdateAiAgentInput(BaseModel): uuid: NonBlankStr name: NonBlankStr repo_uuid: NonBlankStr - behaviors: list[BehaviorInput] = Field( - min_length=1, - max_length=MAX_BEHAVIORS, - description="List of behaviors (1 to MAX_BEHAVIORS)", - ) - instruction: str | None = None + behaviors: _AgentBehaviors + instruction: _AgentInstruction | None = None data_source_ids: list[str] = Field(default_factory=list) disabled_at: NonBlankStr | None = None preserve_disabled_at: bool = True diff --git a/packages/sdk/tests/models/test_ai_agent.py b/packages/sdk/tests/models/test_ai_agent.py index db4f5152c..3fe2bf80e 100644 --- a/packages/sdk/tests/models/test_ai_agent.py +++ b/packages/sdk/tests/models/test_ai_agent.py @@ -845,3 +845,56 @@ def test_behavior_input_snake_case_dumps_to_camel_case(): assert "actionId" in dumped assert "action_id" not in dumped assert inp.action_params is not None + + +def _templated_behavior() -> dict: + behavior = _make_behavior() + behavior["template_params"] = {"field": "123"} + behavior["instruction_template"] = "Read %{field:{{field}}} and {456}." + return behavior + + +@pytest.mark.unit +@pytest.mark.parametrize( + "extra", + [{}, {"uuid": "agent-1"}], + ids=["create", "update"], +) +def test_agent_inputs_expand_placeholders_and_normalize_tokens(extra): + model = UpdateAiAgentInput if extra else CreateAiAgentInput + inp = model( + name="A", + repo_uuid="repo-1", + instruction="Use {field:9} and %{10}.", + behaviors=[_templated_behavior()], + **extra, + ) + assert inp.instruction == "Use %{field:9} and %{field:10}." + abp = inp.behaviors[0].action_params.ai_behavior_params + assert abp.instruction == "Read %{field:123} and %{field:456}." + dumped = inp.behaviors[0].model_dump(by_alias=True) + assert "template_params" not in dumped + assert "instruction_template" not in dumped + + +@pytest.mark.unit +def test_agent_input_rejects_placeholder_without_template_params(): + behavior = _make_behavior() + behavior["actionParams"]["aiBehaviorParams"]["instruction"] = "Read {{field}}." + with pytest.raises(ValidationError, match="template_params"): + CreateAiAgentInput( + name="A", repo_uuid="repo-1", instruction="P", behaviors=[behavior] + ) + + +@pytest.mark.unit +def test_agent_input_does_not_expand_behavior_input_instances_again(): + """A validated BehaviorInput passes through, so its text is never re-interpolated.""" + behavior = _make_behavior() + behavior["actionParams"]["aiBehaviorParams"]["instruction"] = "Keep {{literal}}." + validated = BehaviorInput.model_validate(behavior) + inp = UpdateAiAgentInput( + uuid="agent-1", name="A", repo_uuid="repo-1", behaviors=[validated] + ) + assert inp.behaviors[0] is validated + assert inp.instruction is None diff --git a/packages/sdk/tests/services/test_pipefy_facade.py b/packages/sdk/tests/services/test_pipefy_facade.py index d6e1f7d3a..4328db733 100644 --- a/packages/sdk/tests/services/test_pipefy_facade.py +++ b/packages/sdk/tests/services/test_pipefy_facade.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -5,7 +6,9 @@ from pipefy_sdk import __version__ from pipefy_sdk.client import PipefyClient, build_executors -from pipefy_sdk.graphql_executor import GraphQLResult +from pipefy_sdk.exceptions import AiAgentConfigureError +from pipefy_sdk.graphql_executor import GraphQLResult, PipefyGraphQLError +from pipefy_sdk.models.ai_agent import CreateAiAgentInput from pipefy_sdk.services.ai_agent_service import AiAgentService from pipefy_sdk.services.attachment_service import AttachmentService from pipefy_sdk.services.automation_service import AutomationService @@ -700,19 +703,15 @@ async def test_pipefy_client_introspection_methods_delegate_to_introspection_ser @pytest.mark.unit @pytest.mark.asyncio async def test_pipefy_client_ai_agent_write_methods_delegate_to_ai_agent_service(): - """Facade forwards create/update/toggle AI agent to AiAgentService.""" + """Facade forwards update/toggle AI agent to AiAgentService.""" from _shared.ai_agent_test_payloads import minimal_behavior_dict from pipefy_sdk.models.ai_agent import ( BehaviorInput, - CreateAiAgentInput, UpdateAiAgentInput, ) ai_agent_service = AsyncMock() - ai_agent_service.create_agent = AsyncMock( - return_value={"agent_uuid": "new-1", "message": "created"} - ) ai_agent_service.update_agent = AsyncMock( return_value={"agent_uuid": "new-1", "message": "updated"} ) @@ -723,22 +722,6 @@ async def test_pipefy_client_ai_agent_write_methods_delegate_to_ai_agent_service client = PipefyClient.__new__(PipefyClient) client._ai_agent_service = ai_agent_service - cin = CreateAiAgentInput( - name="n", - repo_uuid="00000000-0000-0000-0000-000000000001", - instruction="purpose", - behaviors=[ - BehaviorInput.model_validate( - minimal_behavior_dict(name="b", event_id="evt") - ) - ], - ) - assert await client.create_ai_agent(cin) == { - "agent_uuid": "new-1", - "message": "created", - } - ai_agent_service.create_agent.assert_awaited_once_with(cin) - uin = UpdateAiAgentInput( uuid="00000000-0000-0000-0000-000000000002", name="n", @@ -775,6 +758,113 @@ async def test_pipefy_client_ai_agent_write_methods_delegate_to_ai_agent_service ) +def _create_chain_client( + *, created_disabled_at: str | None, update_error: Exception | None = None +) -> tuple[PipefyClient, AsyncMock]: + ai_agent_service = AsyncMock() + ai_agent_service.create_agent = AsyncMock( + return_value={ + "agent_uuid": "new-1", + "message": "created", + "disabled_at": created_disabled_at, + "active": created_disabled_at is None, + } + ) + ai_agent_service.update_agent = AsyncMock( + side_effect=update_error, + return_value={ + "agent_uuid": "new-1", + "message": "updated", + "disabled_at": None, + "active": True, + }, + ) + client = PipefyClient.__new__(PipefyClient) + client._ai_agent_service = ai_agent_service + return client, ai_agent_service + + +def _create_input(**overrides: Any) -> CreateAiAgentInput: + from _shared.ai_agent_test_payloads import minimal_behavior_dict + + return CreateAiAgentInput( + name="n", + repo_uuid="00000000-0000-0000-0000-000000000001", + instruction="purpose", + behaviors=[minimal_behavior_dict(name="b", event_id="evt")], + data_source_ids=["ds-1"], + **overrides, + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_ai_agent_writes_behaviors_with_a_chained_update(): + """create_ai_agent creates, then updates without preserving the create's disabledAt.""" + client, service = _create_chain_client(created_disabled_at="2026-08-04T12:00:00Z") + cin = _create_input() + + result = await client.create_ai_agent(cin) + + service.create_agent.assert_awaited_once_with(cin) + forwarded = service.update_agent.await_args.args[0] + assert forwarded.uuid == "new-1" + assert (forwarded.name, forwarded.repo_uuid) == (cin.name, cin.repo_uuid) + assert forwarded.instruction == "purpose" + assert forwarded.data_source_ids == ["ds-1"] + assert [b.name for b in forwarded.behaviors] == ["b"] + assert forwarded.disabled_at is None + assert forwarded.preserve_disabled_at is False + assert result == { + "agent_uuid": "new-1", + "message": "AI Agent created and configured successfully. UUID: new-1", + "disabled_at": None, + "active": True, + } + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_ai_agent_inactive_sends_disabled_at_on_the_update(): + client, service = _create_chain_client(created_disabled_at="2026-08-04T12:00:00Z") + + await client.create_ai_agent(_create_input(disabled_at="2026-08-04T12:00:00Z")) + + forwarded = service.update_agent.await_args.args[0] + assert forwarded.disabled_at == "2026-08-04T12:00:00Z" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_ai_agent_update_failure_raises_configure_error(): + """The created agent's UUID survives a failed update, with the cause chained.""" + cause = PipefyGraphQLError([{"message": "RECORD_NOT_SAVED"}]) + client, _ = _create_chain_client( + created_disabled_at="2026-08-04T12:00:00Z", update_error=cause + ) + + with pytest.raises(AiAgentConfigureError) as excinfo: + await client.create_ai_agent(_create_input()) + + assert excinfo.value.agent_uuid == "new-1" + assert excinfo.value.disabled_at == "2026-08-04T12:00:00Z" + assert excinfo.value.__cause__ is cause + assert "new-1" in str(excinfo.value) + assert "RECORD_NOT_SAVED" in str(excinfo.value) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_ai_agent_create_failure_raises_the_original_error(): + client, service = _create_chain_client(created_disabled_at=None) + service.create_agent.side_effect = ValueError("create failed") + + with pytest.raises(ValueError, match="create failed"): + await client.create_ai_agent(_create_input()) + + service.update_agent.assert_not_awaited() + + @pytest.mark.unit @pytest.mark.asyncio async def test_delete_card_relation_delegates_to_internal_api_client(mock_settings): diff --git a/packages/mcp/tests/tools/test_behavior_placeholder_interpolation.py b/packages/sdk/tests/test_behavior_placeholders.py similarity index 99% rename from packages/mcp/tests/tools/test_behavior_placeholder_interpolation.py rename to packages/sdk/tests/test_behavior_placeholders.py index 25fd79f29..eb3461500 100644 --- a/packages/mcp/tests/tools/test_behavior_placeholder_interpolation.py +++ b/packages/sdk/tests/test_behavior_placeholders.py @@ -4,7 +4,7 @@ import pytest -from pipefy_mcp.tools.behavior_placeholder_interpolation import ( +from pipefy_sdk.behavior_placeholders import ( expand_behavior_placeholders, expand_behaviors_placeholders, extract_referenced_field_ids, diff --git a/skills/ai-agents/pipefy-ai-agents/SKILL.md b/skills/ai-agents/pipefy-ai-agents/SKILL.md index 80f8f7071..167078a64 100644 --- a/skills/ai-agents/pipefy-ai-agents/SKILL.md +++ b/skills/ai-agents/pipefy-ai-agents/SKILL.md @@ -201,7 +201,7 @@ On create/update, slug `fieldId` values are resolved to numeric `internal_id`, ` ### 8 — Handle responses - **Success with `agent_uuid`** → confirm `disabled_at` / `active` on the response (active when `disabled_at` is null). -- **Partial failure (UUID returned, behaviors rejected)** → call `update_ai_agent` with the **full required payload**: `uuid`, `repo_uuid` (same pipe UUID used on create), `name`, `instruction`, and complete `behaviors` (full-replace, not patch). Do NOT create a second agent. The create shell is often disabled (`disabled_at` on the partial-failure envelope); update preserves that state — call `toggle_ai_agent_status` after a successful recovery update if you need the agent active. +- **Partial failure (UUID returned, behaviors rejected)** — the MCP envelope carries `agent_uuid`; the SDK raises `AiAgentConfigureError` with `.agent_uuid` → call `update_ai_agent` with the **full required payload**: `uuid`, `repo_uuid` (same pipe UUID used on create), `name`, `instruction`, and complete `behaviors` (full-replace, not patch). Do NOT create a second agent. The create shell is often disabled (`disabled_at` on the partial-failure envelope); update preserves that state — call `toggle_ai_agent_status` after a successful recovery update if you need the agent active. - **Failure without UUID** → validation or API error. Trust the hint text in the enriched error. ### 9 — Verify @@ -274,7 +274,7 @@ Instructions accept five token aliases — all normalize to canonical `%{field:< ## Template params / placeholders -Per behavior you can pass `template_params` (or `placeholders`) with `str → str` values and use `{{name}}` in any string (instruction, metadata IDs, etc.). Optionally set `instruction_template` instead of `aiBehaviorParams.instruction` — the tool interpolates and writes the final instruction before the API call. These keys are stripped before validation. +Per behavior you can pass `template_params` (or `placeholders`) with `str → str` values and use `{{name}}` in any string (instruction, metadata IDs, etc.). Optionally set `instruction_template` instead of `aiBehaviorParams.instruction` — the final instruction is interpolated and written before the API call (by the MCP tools, the CLI, and the SDK's `CreateAiAgentInput` / `UpdateAiAgentInput`). These keys are stripped during validation. ```json { From 3819a92fc5874f6a3e24410fa59c1bf440157d3a Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:55:33 -0300 Subject: [PATCH 2/5] fix(sdk): report a failed configure-input build as AiAgentConfigureError Build UpdateAiAgentInput inside the try, so every failure after the create raises AiAgentConfigureError with the created agent_uuid. The CHANGELOG entry names the literal {{name}} rejection for SDK callers and the double-expansion case for callers that still expand before validation. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- CHANGELOG.md | 2 +- packages/sdk/src/pipefy_sdk/client.py | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6bd3976..fba0df996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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. 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) +- **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 diff --git a/packages/sdk/src/pipefy_sdk/client.py b/packages/sdk/src/pipefy_sdk/client.py index 07f348324..7f77bd959 100644 --- a/packages/sdk/src/pipefy_sdk/client.py +++ b/packages/sdk/src/pipefy_sdk/client.py @@ -1408,18 +1408,19 @@ async def create_ai_agent( """ created = await self._ai_agent_service.create_agent(agent_input) agent_uuid = created["agent_uuid"] - update_input = UpdateAiAgentInput( - uuid=agent_uuid, - name=agent_input.name, - repo_uuid=agent_input.repo_uuid, - instruction=agent_input.instruction, - behaviors=agent_input.behaviors, - data_source_ids=agent_input.data_source_ids, - disabled_at=agent_input.disabled_at, - preserve_disabled_at=False, - ) try: - updated = await self.update_ai_agent(update_input) + updated = await self.update_ai_agent( + UpdateAiAgentInput( + uuid=agent_uuid, + name=agent_input.name, + repo_uuid=agent_input.repo_uuid, + instruction=agent_input.instruction, + behaviors=agent_input.behaviors, + data_source_ids=agent_input.data_source_ids, + disabled_at=agent_input.disabled_at, + preserve_disabled_at=False, + ) + ) except Exception as exc: raise AiAgentConfigureError( agent_uuid=agent_uuid, From c8ad5b4437f4da82461d78cbac96d13d2930af2c Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:25:37 -0300 Subject: [PATCH 3/5] fix(sdk): expand behaviors passed as a tuple or generator Pydantic coerces any iterable into list[BehaviorInput], but the placeholder step ran only for a list, so a tuple or generator silently dropped template_params and instruction_template. Expand every iterable except str, bytes and dict. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- packages/sdk/src/pipefy_sdk/models/ai_agent.py | 4 +++- packages/sdk/tests/models/test_ai_agent.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/pipefy_sdk/models/ai_agent.py b/packages/sdk/src/pipefy_sdk/models/ai_agent.py index fad500ed3..0cedc1a1c 100644 --- a/packages/sdk/src/pipefy_sdk/models/ai_agent.py +++ b/packages/sdk/src/pipefy_sdk/models/ai_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Annotated, Any, Self from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator @@ -424,7 +425,8 @@ def _expand_raw_behaviors(value: object) -> object: """ from pipefy_sdk.behavior_placeholders import expand_behavior_placeholders - if not isinstance(value, list): + # Any iterable, not only list: pydantic also coerces tuples and generators. + if isinstance(value, (str, bytes, dict)) or not isinstance(value, Iterable): return value return [ expand_behavior_placeholders(b) if isinstance(b, dict) else b for b in value diff --git a/packages/sdk/tests/models/test_ai_agent.py b/packages/sdk/tests/models/test_ai_agent.py index 3fe2bf80e..593ee1d98 100644 --- a/packages/sdk/tests/models/test_ai_agent.py +++ b/packages/sdk/tests/models/test_ai_agent.py @@ -897,4 +897,20 @@ def test_agent_input_does_not_expand_behavior_input_instances_again(): uuid="agent-1", name="A", repo_uuid="repo-1", behaviors=[validated] ) assert inp.behaviors[0] is validated - assert inp.instruction is None + + +@pytest.mark.unit +@pytest.mark.parametrize( + "wrap", + [tuple, lambda items: (b for b in items)], + ids=["tuple", "generator"], +) +def test_agent_input_expands_behaviors_from_any_iterable(wrap): + inp = CreateAiAgentInput( + name="A", + repo_uuid="repo-1", + instruction="P", + behaviors=wrap([_templated_behavior()]), + ) + abp = inp.behaviors[0].action_params.ai_behavior_params + assert abp.instruction == "Read %{field:123} and %{field:456}." From fa7e2a1dbaacc5ecfe4b487118b89359cf653909 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:28:39 -0300 Subject: [PATCH 4/5] fix(sdk): name the disabled state and toggle recovery in AiAgentConfigureError Answers the review thread on exceptions.py:27. The create leaves the agent disabled and a routine update_ai_agent preserves that, so the error, its docstring and the README Errors bullet now say so and name toggle_ai_agent_status. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- docs/sdk/README.md | 2 +- .../cli/tests/test_cli_agent_automation_smoke.py | 7 +++++-- packages/sdk/src/pipefy_sdk/exceptions.py | 15 ++++++++++++--- packages/sdk/tests/services/test_pipefy_facade.py | 7 +++++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/sdk/README.md b/docs/sdk/README.md index e1156e116..3542b4113 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -34,7 +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: `.agent_uuid` identifies it for a recovery `update_ai_agent` or a `delete_ai_agent`, and `.disabled_at` is the create's `disabledAt`. The update's own error is `__cause__`. +- **`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. diff --git a/packages/cli/tests/test_cli_agent_automation_smoke.py b/packages/cli/tests/test_cli_agent_automation_smoke.py index 1c0542ae7..8e0c6d973 100644 --- a/packages/cli/tests/test_cli_agent_automation_smoke.py +++ b/packages/cli/tests/test_cli_agent_automation_smoke.py @@ -493,8 +493,11 @@ def test_agent_create_update_failure_prints_created_uuid_and_error_code( ) assert r.exit_code == 1 - assert "uuid-1" in r.stderr - assert "Invalid (RECORD_NOT_SAVED)" in r.stderr + 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( diff --git a/packages/sdk/src/pipefy_sdk/exceptions.py b/packages/sdk/src/pipefy_sdk/exceptions.py index 27c1f2019..f03927400 100644 --- a/packages/sdk/src/pipefy_sdk/exceptions.py +++ b/packages/sdk/src/pipefy_sdk/exceptions.py @@ -14,8 +14,10 @@ class PipefyAPIError(PipefyError): class AiAgentConfigureError(PipefyError): """Raised when ``create_ai_agent`` created the agent but its configure update failed. - The agent exists: recover with ``update_ai_agent(agent_uuid, ...)`` or remove it - with ``delete_ai_agent``. The update's error is the ``__cause__``. + The agent exists and is disabled when ``disabled_at`` is set. Write its + behaviors with ``update_ai_agent(agent_uuid, ...)``, which keeps it disabled, + then activate it with ``toggle_ai_agent_status``; or remove it with + ``delete_ai_agent``. The update's error is the ``__cause__``. """ def __init__( @@ -23,10 +25,17 @@ def __init__( ) -> None: self.agent_uuid = agent_uuid self.disabled_at = disabled_at - super().__init__( + message = ( f"AI Agent {agent_uuid} was created, but writing its instruction " f"and behaviors failed: {reason}" ) + if disabled_at is not None: + message += ( + f" The agent is disabled (disabledAt {disabled_at}). A routine " + "update_ai_agent keeps it disabled; after a successful update, " + "call toggle_ai_agent_status to activate it." + ) + super().__init__(message) class PortalPermissionError(ValueError): diff --git a/packages/sdk/tests/services/test_pipefy_facade.py b/packages/sdk/tests/services/test_pipefy_facade.py index 4328db733..76a5424d5 100644 --- a/packages/sdk/tests/services/test_pipefy_facade.py +++ b/packages/sdk/tests/services/test_pipefy_facade.py @@ -849,8 +849,11 @@ async def test_create_ai_agent_update_failure_raises_configure_error(): assert excinfo.value.agent_uuid == "new-1" assert excinfo.value.disabled_at == "2026-08-04T12:00:00Z" assert excinfo.value.__cause__ is cause - assert "new-1" in str(excinfo.value) - assert "RECORD_NOT_SAVED" in str(excinfo.value) + message = str(excinfo.value) + assert "new-1" in message + assert "RECORD_NOT_SAVED" in message + assert "is disabled" in message + assert "toggle_ai_agent_status" in message @pytest.mark.unit From 787a0bee6283ce19373e2818f247ab4766e07b74 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:28:39 -0300 Subject: [PATCH 5/5] test(cli): stub create_ai_agent in the agent create lifecycle tests Answers the review thread on test_cli_agent_lifecycle.py:48. The tests assert only the CreateAiAgentInput the command builds; the SDK facade tests own the create-then-update chain assertions. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- .../cli/tests/test_cli_agent_lifecycle.py | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/packages/cli/tests/test_cli_agent_lifecycle.py b/packages/cli/tests/test_cli_agent_lifecycle.py index 74ce98e07..49f2249fc 100644 --- a/packages/cli/tests/test_cli_agent_lifecycle.py +++ b/packages/cli/tests/test_cli_agent_lifecycle.py @@ -4,10 +4,8 @@ import json from datetime import datetime -from types import MethodType from unittest.mock import AsyncMock, MagicMock, patch -from pipefy_sdk import PipefyClient from typer.testing import CliRunner from pipefy_cli.main import app @@ -38,23 +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 = MethodType(PipefyClient.create_ai_agent, mock_client) - mock_client._ai_agent_service.create_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( + mock_client.create_ai_agent = AsyncMock( return_value={ "agent_uuid": "active-uuid", + "message": "AI Agent created and configured successfully. UUID: active-uuid", "disabled_at": None, "active": True, } @@ -100,31 +91,21 @@ def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain( assert body["disabled_at"] is None assert body["active"] is True - create_arg = mock_client._ai_agent_service.create_agent.call_args.args[0] + 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 = MethodType(PipefyClient.create_ai_agent, mock_client) - mock_client._ai_agent_service.create_agent = AsyncMock( - return_value={ - "agent_uuid": "inactive-uuid", - "disabled_at": stub_disabled_at, - "active": False, - } - ) - mock_client.update_ai_agent = AsyncMock( + mock_client.create_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, } @@ -171,12 +152,9 @@ def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain( assert body["disabled_at"] == stub_disabled_at assert body["active"] is False - create_arg = mock_client._ai_agent_service.create_agent.call_args.args[0] + 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(