diff --git a/CHANGELOG.md b/CHANGELOG.md index 2819fa945..fba0df996 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. 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) diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 00ef7f6d6..3542b4113 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, 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. @@ -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..8e0c6d973 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,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 ): diff --git a/packages/cli/tests/test_cli_agent_lifecycle.py b/packages/cli/tests/test_cli_agent_lifecycle.py index 132ba8bd8..49f2249fc 100644 --- a/packages/cli/tests/test_cli_agent_lifecycle.py +++ b/packages/cli/tests/test_cli_agent_lifecycle.py @@ -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, } @@ -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, } @@ -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( 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..7f77bd959 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,43 @@ 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"] + try: + 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, + 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 +1439,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..f03927400 100644 --- a/packages/sdk/src/pipefy_sdk/exceptions.py +++ b/packages/sdk/src/pipefy_sdk/exceptions.py @@ -11,5 +11,32 @@ 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 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__( + self, *, agent_uuid: str, disabled_at: str | None, reason: str + ) -> None: + self.agent_uuid = agent_uuid + self.disabled_at = disabled_at + 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): """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..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 @@ -404,17 +405,59 @@ 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 - name: NonBlankStr - repo_uuid: NonBlankStr - instruction: NonBlankStr - behaviors: list[BehaviorInput] = Field( + return ( + normalize_pipefy_ai_instruction_tokens(value) + if isinstance(value, str) + else value + ) + + +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 + + # 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 + ] + + +_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 +465,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 +479,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..593ee1d98 100644 --- a/packages/sdk/tests/models/test_ai_agent.py +++ b/packages/sdk/tests/models/test_ai_agent.py @@ -845,3 +845,72 @@ 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 + + +@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}." diff --git a/packages/sdk/tests/services/test_pipefy_facade.py b/packages/sdk/tests/services/test_pipefy_facade.py index d6e1f7d3a..76a5424d5 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,116 @@ 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 + 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 +@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 {