From aa21c95cb808fef1f0a5cc638fbc9e62b9ceac9b Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:34:39 -0300 Subject: [PATCH 1/2] feat: knowledge base list, plain text CRUD, and access probe Add get_ai_knowledge_bases, get_ai_knowledge_base_plain_text, create/update/delete_ai_knowledge_base_plain_text, and validate_knowledge_base_access with full SDK + MCP + CLI parity. All operations are pipe-scoped by pipe UUID (not the numeric id). Plain text limits are enforced client-side to fail fast: content 1-3500 and description 1-900 characters, both required (the backend rejects a blank description even though the GraphQL schema marks it optional). CLI gates writes on the read-access probe; MCP stays explicit-validate-first; deletes require confirmation. validate_ai_agent_behaviors gains optional data_source_ids: agent-level IDs are unioned with each behavior's dataSourceIds and checked against the pipe's knowledge bases, warning (never blocking) on unknown IDs and skipping the check with a single warning when the list cannot be read. Closes #412 --- .github/workflows/scripts/lint_skill_refs.py | 1 + README.md | 5 +- docs/mcp/README.md | 1 + docs/mcp/tools/knowledge-bases.md | 46 +++ docs/parity.md | 10 +- packages/cli/src/pipefy_cli/commands/agent.py | 10 + .../src/pipefy_cli/commands/knowledge_base.py | 199 ++++++++++ packages/cli/src/pipefy_cli/main.py | 2 + .../cli/tests/fixtures/cli_help_golden.txt | 178 ++++++++- .../cli/tests/test_knowledge_base_commands.py | 276 ++++++++++++++ .../src/pipefy_mcp/tools/ai_agent_tools.py | 8 + .../pipefy_mcp/tools/knowledge_base_tools.py | 318 ++++++++++++++++ packages/mcp/src/pipefy_mcp/tools/registry.py | 8 + .../tests/tools/test_knowledge_base_tools.py | 326 ++++++++++++++++ packages/sdk/src/pipefy_sdk/__init__.py | 8 + packages/sdk/src/pipefy_sdk/ai_preflight.py | 97 +++++ packages/sdk/src/pipefy_sdk/client.py | 67 ++++ .../queries/knowledge_base_queries.py | 100 +++++ .../services/knowledge_base_service.py | 278 ++++++++++++++ packages/sdk/src/pipefy_sdk/services/types.py | 54 +++ .../services/test_knowledge_base_service.py | 359 ++++++++++++++++++ .../tests/test_ai_preflight_data_sources.py | 187 +++++++++ skills/ai-agents/pipefy-ai-agents/SKILL.md | 26 +- 23 files changed, 2548 insertions(+), 16 deletions(-) create mode 100644 docs/mcp/tools/knowledge-bases.md create mode 100644 packages/cli/src/pipefy_cli/commands/knowledge_base.py create mode 100644 packages/cli/tests/test_knowledge_base_commands.py create mode 100644 packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py create mode 100644 packages/mcp/tests/tools/test_knowledge_base_tools.py create mode 100644 packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py create mode 100644 packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py create mode 100644 packages/sdk/tests/services/test_knowledge_base_service.py create mode 100644 packages/sdk/tests/test_ai_preflight_data_sources.py diff --git a/.github/workflows/scripts/lint_skill_refs.py b/.github/workflows/scripts/lint_skill_refs.py index f6ae66ca..7d165447 100644 --- a/.github/workflows/scripts/lint_skill_refs.py +++ b/.github/workflows/scripts/lint_skill_refs.py @@ -26,6 +26,7 @@ "field-condition", "graphql", "introspect", + "kb", "label", "member", "org", diff --git a/README.md b/README.md index 581ce1eb..502c0e4a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Open-source toolkit for **Pipefy** developers: a Model Context Protocol (MCP) se | Component | Package / path | Purpose | |-----------|----------------|---------| -| **MCP server** | `pipefy-mcp-server` | Exposes **162** tools to MCP clients (Cursor, Claude Desktop, Claude Code, and others). | +| **MCP server** | `pipefy-mcp-server` | Exposes **168** tools to MCP clients (Cursor, Claude Desktop, Claude Code, and others). | | **CLI** | `pipefy-cli` | Terminal commands aligned with MCP capabilities; see [`docs/parity.md`](docs/parity.md). | | **SDK** | `pipefy` | Vendor GraphQL client, services, and models shared by MCP and CLI. | | **Skills** | [`skills/`](skills/) | Markdown playbooks (Anthropic Skills format) for common Pipefy workflows. | @@ -141,7 +141,7 @@ Deprecation and semver (post-1.0): [`docs/DEPRECATION.md`](docs/DEPRECATION.md). ## MCP server -The server registers **162 tools** across twelve domains. Canonical names: `PIPEFY_TOOL_NAMES` in [`packages/mcp/src/pipefy_mcp/tools/registry.py`](packages/mcp/src/pipefy_mcp/tools/registry.py). +The server registers **168 tools** across thirteen domains. Canonical names: `PIPEFY_TOOL_NAMES` in [`packages/mcp/src/pipefy_mcp/tools/registry.py`](packages/mcp/src/pipefy_mcp/tools/registry.py). Tool descriptions and `Args:` blocks come from Python docstrings (what MCP clients show to models). Per-area reference docs cover parameters, edge cases, and cross-cutting behavior. @@ -155,6 +155,7 @@ Tool descriptions and `Args:` blocks come from Python docstrings (what MCP clien | **Reports** | 17 | Pipe and organization reports, async exports. | [docs](docs/mcp/tools/reports.md) | | **Automations & AI** | 23 | Automations, AI automations, AI agents, validators. | [docs](docs/mcp/tools/automations-and-ai.md) | | **LLM providers** | 5 | Discovery reads for the organization's LLM providers (custom + Pipefy-managed), vendor model lists, owner defaults, dependencies, and a read-access probe. | [docs](docs/mcp/tools/llm-providers.md) | +| **Knowledge bases** | 6 | Pipe-scoped AI knowledge bases: list all items, plain text CRUD, and a read-access probe. Attach sources to agents/behaviors via `dataSourceIds`. | [docs](docs/mcp/tools/knowledge-bases.md) | | **iPaaS** | 4 | Lazy discovery, invocation, and app-connection setup for a pipe's iPaaS (Advanced Automations) workspace (`get_ipaas_tools`, `call_ipaas_tool`, plus the connection meta-tools). | [docs](docs/mcp/tools/ipaas.md) | | **Observability** | 11 | Logs, usage, credits, execution metrics, job exports. | [docs](docs/mcp/tools/observability.md) | | **Members, email & webhooks** | 11 | Membership, inbox email, webhooks. | [docs](docs/mcp/tools/members-email-webhooks.md) | diff --git a/docs/mcp/README.md b/docs/mcp/README.md index 706ecd80..55b7f80f 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -13,6 +13,7 @@ Material in this tree describes **`pipefy-mcp-server`**: the MCP process, tool b | [`tools/reports.md`](tools/reports.md) | Pipe and organization reports, async exports | | [`tools/automations-and-ai.md`](tools/automations-and-ai.md) | Traditional automations, AI automations, AI agents, validators | | [`tools/llm-providers.md`](tools/llm-providers.md) | LLM provider discovery: custom + system providers, vendor models, defaults, dependencies, access probe | +| [`tools/knowledge-bases.md`](tools/knowledge-bases.md) | Pipe-scoped AI knowledge bases: list, plain text CRUD, read-access probe | | [`tools/observability.md`](tools/observability.md) | Logs, usage, credits, execution metrics, job exports | | [`tools/members-email-webhooks.md`](tools/members-email-webhooks.md) | Membership, inbox email, webhooks | | [`tools/organization.md`](tools/organization.md) | Organization metadata | diff --git a/docs/mcp/tools/knowledge-bases.md b/docs/mcp/tools/knowledge-bases.md new file mode 100644 index 00000000..c77dca4c --- /dev/null +++ b/docs/mcp/tools/knowledge-bases.md @@ -0,0 +1,46 @@ +# Knowledge bases + +Pipe-scoped AI knowledge bases: list every item on a pipe, full CRUD for plain-text sources, and a read-access probe. **6 tools.** + +Knowledge bases are the data sources an AI agent draws on. Each item's `id` is what you attach to an agent or behavior via `dataSourceIds` (see [Automations & AI](automations-and-ai.md)): use `get_ai_knowledge_bases` to discover the IDs, then `validate_ai_agent_behaviors(data_source_ids=[...])` to check membership before writing the agent. + +--- + +| Tool | Read-only | Role | +|------|-----------|------| +| `get_ai_knowledge_bases` | Yes | Lists every knowledge base item on a pipe (plain texts, documents, data lookups) as one flat list — no pagination. Each item carries `id`, `type` (e.g. `knowledge_base_plain_texts`), `name`, `description`, `updatedAt`. | +| `get_ai_knowledge_base_plain_text` | Yes | Fetches one plain text by `id`, including its full `content`. | +| `create_ai_knowledge_base_plain_text` | No | Creates a plain text. `name`, `content` (1-3500 chars), and `description` (1-900 chars) are all required. | +| `update_ai_knowledge_base_plain_text` | No | Partial update by `plain_text_id`: pass any of `name` / `content` / `description` (at least one); omitted fields keep their stored value. | +| `delete_ai_knowledge_base_plain_text` | No | Deletes a plain text permanently. Two-step: preview with `confirm=false` (default), execute with `confirm=true`. | +| `validate_knowledge_base_access` | Yes | Probes whether the current credential can read a pipe's knowledge bases, classifying failures into structured problems instead of opaque errors. | + +## Identifiers: pipe UUID, not numeric ID + +Every knowledge base operation is scoped by the pipe **UUID** (`pipe_uuid`), not the numeric pipe ID — this follows the Pipefy GraphQL API. `get_pipe` returns the `uuid` field; `get_ai_knowledge_bases` returns each item's `id` (a data-source UUID) for the plain-text-by-id operations and for `dataSourceIds`. + +## Plain-text limits (enforced client-side) + +Limits fail fast before the network call, so an over-limit value is rejected with an actionable message rather than a backend 422: + +- `content`: required, 1-3500 characters. +- `description`: required, 1-900 characters. The GraphQL schema marks `description` optional, but the backend rejects a blank one, so the toolkit requires it on create. +- `name`: required, non-blank. + +On update, only the fields you pass are validated and sent; the others keep their stored values. + +## Probe semantics and the write gate + +- A green `validate_knowledge_base_access` proves **read access only** (`read_ai_agents` on the pipe) — never the `manage_ai_agents` entitlement that plain-text create / update / delete require. +- An **empty knowledge base list** is a valid green result (`knowledge_base_count: 0`), not a failure. +- The **CLI gates writes** on the probe: `pipefy kb plain-text create` / `update` run the read-access probe first and fail with the classified problem if it is denied, before attempting the mutation. +- The **MCP tools stay explicit-validate-first**: create / update do not auto-probe. Call `validate_knowledge_base_access` yourself before writing. +- **Deletes require confirmation**: the MCP tool needs `confirm=true`; the CLI needs `--yes` (or an interactive prompt). + +## Attaching a source to an agent + +`validate_ai_agent_behaviors` accepts an optional `data_source_ids` (agent-level). It is unioned with each behavior's `actionParams.aiBehaviorParams.dataSourceIds` and checked against the pipe's knowledge bases: IDs not present on the pipe produce **warnings only** (`valid` stays true). If the knowledge base list cannot be read, a single warning is added and the membership check is skipped — a read failure is never reported as a broken reference. + +## Error classification + +Failures on this surface are classified by the shared SDK-level module (`pipefy_sdk.graphql_problem`) into structured problems — `permission_denied`, `not_found`, `invalid_arguments`, `feature_not_enabled`, or `runtime` — carried on `error.details.kind` alongside the GraphQL `extensions.code` and `correlation_id`. The same classifier backs the CLI (`pipefy kb ...`), so both surfaces report the same problem kinds. diff --git a/docs/parity.md b/docs/parity.md index 53e0d6ee..48040154 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ This matrix is the source of truth for **MCP tool ↔ `pipefy` CLI** coverage. Update it whenever MCP tools or CLI commands are added, renamed, or removed. -**Registry source:** `PIPEFY_TOOL_NAMES` in `packages/mcp/src/pipefy_mcp/tools/registry.py` (must stay in sync with this table: **162** tools). +**Registry source:** `PIPEFY_TOOL_NAMES` in `packages/mcp/src/pipefy_mcp/tools/registry.py` (must stay in sync with this table: **168** tools). **Later CLI coverage:** areas such as attachments, field conditions, email, audit export, traditional automations, exports/usage, introspection, and raw GraphQL appear as **shipped** below when the matching Typer commands exist in `packages/cli`. @@ -30,6 +30,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `clone_pipe` | `pipefy pipe clone` | shipped | optional `--org`. | | `create_ai_agent` | `pipefy agent create` | shipped | AI Agents domain; post-v0.1 CLI unless explicitly rescoped. | | `create_ai_automation` | `pipefy ai-automation create` | shipped | AI Automations domain; post-v0.1 CLI unless explicitly rescoped. | +| `create_ai_knowledge_base_plain_text` | `pipefy kb plain-text create` | shipped | Knowledge bases; pipe-scoped (`--pipe-uuid`, `--name`, `--content` <=3500, `--description` <=900, all required). CLI gates on the read-access probe; limits fail fast client-side. | | `create_automation` | `pipefy automation create` | shipped | (`--pipe`, `--name`, `--trigger-id`, `--action-id`, optional `--extra` JSON). | | `create_card` | `pipefy card create` | shipped | (`--fields` JSON, optional `--title`, optional `--phase-id` for `CreateCardInput.phase_id`). | | `create_card_relation` | `pipefy relation card create` | shipped | — | @@ -53,6 +54,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `create_webhook` | `pipefy webhook create` | shipped | — | | `delete_ai_agent` | `pipefy agent delete` | shipped | AI Agents domain. | | `delete_ai_automation` | `pipefy ai-automation delete` | shipped | AI Automations domain. | +| `delete_ai_knowledge_base_plain_text` | `pipefy kb plain-text delete` | shipped | Knowledge bases; pipe-scoped (`--id`, `--pipe-uuid`). MCP requires `confirm=true`; CLI requires `--yes` (or interactive prompt). | | `delete_automation` | `pipefy automation delete` | shipped | destructive: `--yes` or confirm. | | `delete_card` | `pipefy card delete` | shipped | destructive: `--yes` or interactive confirm. | | `delete_card_relation` | `pipefy relation card delete` | shipped | requires OAuth (internal API); `--yes`. | @@ -91,6 +93,8 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `get_ai_automation` | `pipefy ai-automation get` | shipped | AI Automations domain. | | `get_ai_automations` | `pipefy ai-automation list` | shipped | AI Automations domain. | | `get_ai_credit_usage` | `pipefy usage credits` | shipped | (`--organization`, `--period`). | +| `get_ai_knowledge_base_plain_text` | `pipefy kb plain-text get` | shipped | Knowledge bases; pipe-scoped plain text with content (`--id`, `--pipe-uuid`). | +| `get_ai_knowledge_bases` | `pipefy kb list` | shipped | Knowledge bases; all items on a pipe (plain text, documents, data lookups) with `id` for `dataSourceIds` (`--pipe-uuid`; no pagination). | | `get_automation` | `pipefy automation get` | shipped | — | | `get_automation_actions` | `pipefy automation actions list` | shipped | (`--pipe`). | | `get_automation_event_attributes` | `pipefy automation event-attributes` | shipped | Official ``field_map`` event-attribute token catalog. | @@ -161,6 +165,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `unpublish_sub_portal` | `pipefy portal sub-portal unpublish` | shipped | internal_api `updateSubPortalElement(subPortalUuid: null)`; sets `subPortals[].published` to false. | | `update_ai_agent` | `pipefy agent update` | shipped | AI Agents domain. | | `update_ai_automation` | `pipefy ai-automation update` | shipped | AI Automations domain. | +| `update_ai_knowledge_base_plain_text` | `pipefy kb plain-text update` | shipped | Knowledge bases; partial update (`--id`, `--pipe-uuid`, optional `--name`/`--content`/`--description`, at least one). CLI gates on the read-access probe; limits fail fast client-side. | | `update_automation` | `pipefy automation update` | shipped | (`--extra` JSON). | | `update_card` | `pipefy card update` | shipped | (`--field-updates` JSON, optional title/labels/assignees/due-date). | | `update_card_field` | `pipefy card update` | shipped | Use `--field-updates` JSON array (). | @@ -186,6 +191,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `upload_attachment_to_table_record` | `pipefy attachment upload --record` | shipped | (same supporting flags as card). | | `validate_ai_agent_behaviors` | `pipefy agent validate-behaviors` | shipped | AI Agents validation tooling. | | `validate_ai_automation_prompt` | `pipefy ai-automation validate-prompt` | shipped | AI Automations validation tooling. | +| `validate_knowledge_base_access` | `pipefy kb validate-access` | shipped | Knowledge base read-access probe (pipe-scoped, `--pipe-uuid`); green proves read access only (`read_ai_agents`), never write entitlement. | | `validate_llm_provider_access` | `pipefy ai-provider validate-access` | shipped | LLM provider read-access probe; green proves read access only, never write entitlement. | ## Row count check @@ -199,6 +205,6 @@ for n in m.body: print(len(v.args[0].elts))" ``` -Expect **162** tool names in `PIPEFY_TOOL_NAMES` and **162** data rows in the parity table (excluding the header rows). +Expect **168** tool names in `PIPEFY_TOOL_NAMES` and **168** data rows in the parity table (excluding the header rows). When adding or removing an MCP tool, update **this file** and `PIPEFY_TOOL_NAMES` in the same change set. diff --git a/packages/cli/src/pipefy_cli/commands/agent.py b/packages/cli/src/pipefy_cli/commands/agent.py index 78dbef48..5110e67e 100644 --- a/packages/cli/src/pipefy_cli/commands/agent.py +++ b/packages/cli/src/pipefy_cli/commands/agent.py @@ -342,6 +342,15 @@ def agent_validate_behaviors( "values as problems; --no-strict reports them as warnings only." ), ), + data_source_id: list[str] = typer.Option( + [], + "--data-source-id", + help=( + "Agent-level knowledge base ID to attach (repeatable). Unioned with " + "behavior-level dataSourceIds and checked against the pipe's knowledge " + "bases; unknown IDs are warnings only." + ), + ), json_out: bool = typer.Option(False, "--json", "-j"), ) -> None: """Dry-run behavior validation (``validate_ai_agent_behaviors``).""" @@ -353,6 +362,7 @@ async def factory(client: PipefyClient): pipe.strip(), behavior_list, strict_unknown_action_types=strict_unknown, + data_source_ids=data_source_id or None, ) run_cli_command(ctx, json_out, factory) diff --git a/packages/cli/src/pipefy_cli/commands/knowledge_base.py b/packages/cli/src/pipefy_cli/commands/knowledge_base.py new file mode 100644 index 00000000..60164ff4 --- /dev/null +++ b/packages/cli/src/pipefy_cli/commands/knowledge_base.py @@ -0,0 +1,199 @@ +"""AI knowledge bases (pipe-scoped): list, plain text CRUD, and access probe.""" + +from __future__ import annotations + +import typer +from pipefy_sdk import PipefyClient + +from pipefy_cli.commands._common import ( + confirm_destructive, + run_cli_command, +) + +kb_app = typer.Typer( + help="AI knowledge bases (pipe-scoped: list, plain text CRUD, access probe).", + no_args_is_help=True, +) +plain_text_app = typer.Typer(help="Knowledge base plain texts.", no_args_is_help=True) +kb_app.add_typer(plain_text_app, name="plain-text") + +_PIPE_UUID_HELP = "Pipe UUID (not the numeric ID; `pipefy pipe get` shows the uuid)." + + +@kb_app.command("list") +def kb_list( + ctx: typer.Context, + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """List a pipe's knowledge base items (``get_ai_knowledge_bases``). + + Each item carries ``id`` (used in ``dataSourceIds``), ``type``, ``name``, + ``description``, and ``updatedAt``. No pagination; the full list is returned. + """ + + async def factory(client: PipefyClient): + items = await client.get_ai_knowledge_bases(pipe_uuid) + return {"success": True, "knowledge_bases": items} + + run_cli_command(ctx, json_out, factory) + + +@kb_app.command("validate-access") +def kb_validate_access( + ctx: typer.Context, + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Probe knowledge-base read access (``validate_knowledge_base_access``). + + A green probe proves read access only (``read_ai_agents``), never write + entitlement. Exits 1 when the probe classifies a failure, after rendering + the structured problem. + """ + + async def factory(client: PipefyClient): + probe = await client.validate_knowledge_base_access(pipe_uuid) + return {"success": bool(probe.get("ok")), **probe} + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +@plain_text_app.command("get") +def kb_plain_text_get( + ctx: typer.Context, + plain_text_id: str = typer.Option( + ..., "--id", help="Knowledge base plain text ID (from `pipefy kb list`)." + ), + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Fetch one knowledge base plain text with its content (``get_ai_knowledge_base_plain_text``).""" + + async def factory(client: PipefyClient): + plain_text = await client.get_ai_knowledge_base_plain_text( + plain_text_id, pipe_uuid + ) + return {"success": True, "knowledge_base_plain_text": plain_text} + + run_cli_command(ctx, json_out, factory) + + +@plain_text_app.command("create") +def kb_plain_text_create( + ctx: typer.Context, + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + name: str = typer.Option(..., "--name", help="Display name (required)."), + content: str = typer.Option( + ..., "--content", help="Plain text content (required, 1-3500 characters)." + ), + description: str = typer.Option( + ..., "--description", help="Description (required, 1-900 characters)." + ), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Create a knowledge base plain text (``create_ai_knowledge_base_plain_text``). + + Gated on the read-access probe: fails with the classified problem if the + credential cannot read the pipe's knowledge bases. Client-side limits + (content 1-3500, description 1-900) fail fast before the mutation is sent. + """ + + async def factory(client: PipefyClient): + gate = await _probe_gate(client, pipe_uuid) + if gate is not None: + return gate + plain_text = await client.create_ai_knowledge_base_plain_text( + pipe_uuid, name=name, content=content, description=description + ) + return {"success": True, "knowledge_base_plain_text": plain_text} + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +@plain_text_app.command("update") +def kb_plain_text_update( + ctx: typer.Context, + plain_text_id: str = typer.Option( + ..., "--id", help="Knowledge base plain text ID to update." + ), + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + name: str | None = typer.Option(None, "--name", help="New name (non-blank)."), + content: str | None = typer.Option( + None, "--content", help="New content (1-3500 characters)." + ), + description: str | None = typer.Option( + None, "--description", help="New description (1-900 characters)." + ), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Update a knowledge base plain text (``update_ai_knowledge_base_plain_text``). + + Partial: only the fields you pass change. Provide at least one of --name, + --content, or --description. Gated on the read-access probe. + """ + + async def factory(client: PipefyClient): + gate = await _probe_gate(client, pipe_uuid) + if gate is not None: + return gate + plain_text = await client.update_ai_knowledge_base_plain_text( + plain_text_id, + pipe_uuid, + name=name, + content=content, + description=description, + ) + return {"success": True, "knowledge_base_plain_text": plain_text} + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +@plain_text_app.command("delete") +def kb_plain_text_delete( + ctx: typer.Context, + plain_text_id: str = typer.Option( + ..., "--id", help="Knowledge base plain text ID to delete." + ), + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt." + ), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Delete a knowledge base plain text permanently (``delete_ai_knowledge_base_plain_text``).""" + confirm_destructive( + yes=yes, + description=f"knowledge base plain text {plain_text_id}", + ) + + async def factory(client: PipefyClient): + return await client.delete_ai_knowledge_base_plain_text( + plain_text_id, pipe_uuid + ) + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +async def _probe_gate(client: PipefyClient, pipe_uuid: str) -> dict | None: + """Gate a write on the read-access probe; return a failure dict or None. + + A failed probe returns the classified problem so the write never runs and + the CLI exits 1 (via ``exit_1_on_unsuccessful``) with the problem rendered. + """ + probe = await client.validate_knowledge_base_access(pipe_uuid) + if probe.get("ok"): + return None + return {"success": False, **probe} diff --git a/packages/cli/src/pipefy_cli/main.py b/packages/cli/src/pipefy_cli/main.py index a0e9d79a..8ae44d0c 100644 --- a/packages/cli/src/pipefy_cli/main.py +++ b/packages/cli/src/pipefy_cli/main.py @@ -21,6 +21,7 @@ from pipefy_cli.commands.field_condition import field_condition_app from pipefy_cli.commands.graphql import graphql_app from pipefy_cli.commands.introspect import introspect_app +from pipefy_cli.commands.knowledge_base import kb_app from pipefy_cli.commands.label import label_app from pipefy_cli.commands.member import member_app from pipefy_cli.commands.org import org_app @@ -127,6 +128,7 @@ def main( app.add_typer(member_app, name="member") app.add_typer(graphql_app, name="graphql") app.add_typer(introspect_app, name="introspect") +app.add_typer(kb_app, name="kb") app.add_typer(export_app, name="export") app.add_typer(org_app, name="org") app.add_typer(report_org_app, name="report-org") diff --git a/packages/cli/tests/fixtures/cli_help_golden.txt b/packages/cli/tests/fixtures/cli_help_golden.txt index 06855c57..b7fe13c5 100644 --- a/packages/cli/tests/fixtures/cli_help_golden.txt +++ b/packages/cli/tests/fixtures/cli_help_golden.txt @@ -58,6 +58,8 @@ Usage: pipefy [OPTIONS] COMMAND [ARGS]... │ graphql Execute arbitrary GraphQL (mutations require --yes). See │ │ docs/cli/self-healing.md. │ │ introspect Discover GraphQL types and operations (JSON default). │ +│ kb AI knowledge bases (pipe-scoped: list, plain text CRUD, │ +│ access probe). │ │ export Bulk exports (automation jobs). │ │ org Organization operations. │ │ report-org Organization reports. │ @@ -244,17 +246,25 @@ Usage: pipefy agent validate-behaviors [OPTIONS] Dry-run behavior validation (``validate_ai_agent_behaviors``). ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ * --pipe TEXT Numeric pipe id. [required] │ -│ * --behaviors TEXT JSON array of behavior objects. │ -│ [required] │ -│ --strict --no-strict Pre-flight strictness: --strict │ -│ (default) reports unknown │ -│ actionType values as problems; │ -│ --no-strict reports them as │ -│ warnings only. │ -│ [default: strict] │ -│ --json -j │ -│ --help Show this message and exit. │ +│ * --pipe TEXT Numeric pipe id. [required] │ +│ * --behaviors TEXT JSON array of behavior │ +│ objects. │ +│ [required] │ +│ --strict --no-strict Pre-flight strictness: │ +│ --strict (default) reports │ +│ unknown actionType values as │ +│ problems; --no-strict reports │ +│ them as warnings only. │ +│ [default: strict] │ +│ --data-source-id TEXT Agent-level knowledge base ID │ +│ to attach (repeatable). │ +│ Unioned with behavior-level │ +│ dataSourceIds and checked │ +│ against the pipe's knowledge │ +│ bases; unknown IDs are │ +│ warnings only. │ +│ --json -j │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ### HELP ai-automation @@ -1639,6 +1649,152 @@ Usage: pipefy introspect type [OPTIONS] NAME │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ +### HELP kb +Usage: pipefy kb [OPTIONS] COMMAND [ARGS]... + + AI knowledge bases (pipe-scoped: list, plain text CRUD, access probe). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ list List a pipe's knowledge base items │ +│ (``get_ai_knowledge_bases``). │ +│ validate-access Probe knowledge-base read access │ +│ (``validate_knowledge_base_access``). │ +│ plain-text Knowledge base plain texts. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/list +Usage: pipefy kb list [OPTIONS] + + List a pipe's knowledge base items (``get_ai_knowledge_bases``). + + Each item carries ``id`` (used in ``dataSourceIds``), ``type``, ``name``, + ``description``, and ``updatedAt``. No pagination; the full list is returned. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/plain-text +Usage: pipefy kb plain-text [OPTIONS] COMMAND [ARGS]... + + Knowledge base plain texts. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ get Fetch one knowledge base plain text with its content │ +│ (``get_ai_knowledge_base_plain_text``). │ +│ create Create a knowledge base plain text │ +│ (``create_ai_knowledge_base_plain_text``). │ +│ update Update a knowledge base plain text │ +│ (``update_ai_knowledge_base_plain_text``). │ +│ delete Delete a knowledge base plain text permanently │ +│ (``delete_ai_knowledge_base_plain_text``). │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/plain-text/create +Usage: pipefy kb plain-text create [OPTIONS] + + Create a knowledge base plain text (``create_ai_knowledge_base_plain_text``). + + Gated on the read-access probe: fails with the classified problem if the + credential cannot read the pipe's knowledge bases. Client-side limits (content + 1-3500, description 1-900) fail fast before the mutation is sent. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ * --name TEXT Display name (required). [required] │ +│ * --content TEXT Plain text content (required, 1-3500 │ +│ characters). │ +│ [required] │ +│ * --description TEXT Description (required, 1-900 characters). │ +│ [required] │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/plain-text/delete +Usage: pipefy kb plain-text delete [OPTIONS] + + Delete a knowledge base plain text permanently + (``delete_ai_knowledge_base_plain_text``). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --id TEXT Knowledge base plain text ID to delete. │ +│ [required] │ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --yes -y Skip the confirmation prompt. │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/plain-text/get +Usage: pipefy kb plain-text get [OPTIONS] + + Fetch one knowledge base plain text with its content + (``get_ai_knowledge_base_plain_text``). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --id TEXT Knowledge base plain text ID (from `pipefy kb │ +│ list`). │ +│ [required] │ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/plain-text/update +Usage: pipefy kb plain-text update [OPTIONS] + + Update a knowledge base plain text (``update_ai_knowledge_base_plain_text``). + + Partial: only the fields you pass change. Provide at least one of --name, + --content, or --description. Gated on the read-access probe. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --id TEXT Knowledge base plain text ID to update. │ +│ [required] │ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --name TEXT New name (non-blank). │ +│ --content TEXT New content (1-3500 characters). │ +│ --description TEXT New description (1-900 characters). │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/validate-access +Usage: pipefy kb validate-access [OPTIONS] + + Probe knowledge-base read access (``validate_knowledge_base_access``). + + A green probe proves read access only (``read_ai_agents``), never write + entitlement. Exits 1 when the probe classifies a failure, after rendering the + structured problem. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + ### HELP label Usage: pipefy label [OPTIONS] COMMAND [ARGS]... diff --git a/packages/cli/tests/test_knowledge_base_commands.py b/packages/cli/tests/test_knowledge_base_commands.py new file mode 100644 index 00000000..9122e10f --- /dev/null +++ b/packages/cli/tests/test_knowledge_base_commands.py @@ -0,0 +1,276 @@ +"""Tests for ``pipefy kb`` knowledge base commands.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pipefy_cli.main import app + +PLAIN_TEXT_NODE = { + "id": "kb-1", + "type": "knowledge_base_plain_texts", + "name": "Onboarding", + "description": "How to onboard", + "updatedAt": "2026-07-17T00:00:00Z", +} + +PLAIN_TEXT_FULL = { + "id": "kb-1", + "name": "Onboarding", + "description": "How to onboard", + "content": "Step 1...", + "updatedAt": "2026-07-17T00:00:00Z", +} + +GREEN_PROBE = {"ok": True, "knowledge_base_count": 1, "note": "Read access confirmed."} +DENIED_PROBE = { + "ok": False, + "problem": { + "kind": "permission_denied", + "message": "Permission denied", + "code": "PERMISSION_DENIED", + }, +} + + +def _env(monkeypatch) -> None: + monkeypatch.setenv("PIPEFY_SERVICE_ACCOUNT_CLIENT_ID", "cid") + monkeypatch.setenv("PIPEFY_SERVICE_ACCOUNT_CLIENT_SECRET", "sec") + + +def _client_patch(mock_client): + return patch( + "pipefy_cli.commands._common.get_authenticated_client", + return_value=mock_client, + ) + + +def test_kb_list_json(runner, clean_pipefy_env, saved_cwd, monkeypatch): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.get_ai_knowledge_bases = AsyncMock(return_value=[PLAIN_TEXT_NODE]) + + with _client_patch(mock_client): + result = runner.invoke( + app, ["kb", "list", "--pipe-uuid", "pipe-uuid-1", "--json"] + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + data = json.loads(result.stdout) + assert data["success"] is True + assert data["knowledge_bases"] == [PLAIN_TEXT_NODE] + mock_client.get_ai_knowledge_bases.assert_awaited_once_with("pipe-uuid-1") + + +def test_kb_validate_access_green_exits_0( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=GREEN_PROBE) + + with _client_patch(mock_client): + result = runner.invoke( + app, ["kb", "validate-access", "--pipe-uuid", "pipe-uuid-1", "--json"] + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + assert json.loads(result.stdout)["success"] is True + + +def test_kb_validate_access_failure_exits_1( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=DENIED_PROBE) + + with _client_patch(mock_client): + result = runner.invoke( + app, ["kb", "validate-access", "--pipe-uuid", "pipe-uuid-1", "--json"] + ) + assert result.exit_code == 1 + assert json.loads(result.stdout)["success"] is False + + +def test_kb_plain_text_get_json(runner, clean_pipefy_env, saved_cwd, monkeypatch): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.get_ai_knowledge_base_plain_text = AsyncMock( + return_value=PLAIN_TEXT_FULL + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "get", + "--id", + "kb-1", + "--pipe-uuid", + "pipe-uuid-1", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + assert json.loads(result.stdout)["knowledge_base_plain_text"] == PLAIN_TEXT_FULL + mock_client.get_ai_knowledge_base_plain_text.assert_awaited_once_with( + "kb-1", "pipe-uuid-1" + ) + + +def test_kb_plain_text_create_gated_on_probe( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=GREEN_PROBE) + mock_client.create_ai_knowledge_base_plain_text = AsyncMock( + return_value=PLAIN_TEXT_FULL + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "create", + "--pipe-uuid", + "pipe-uuid-1", + "--name", + "Onboarding", + "--content", + "Step 1...", + "--description", + "How to onboard", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + assert json.loads(result.stdout)["success"] is True + mock_client.create_ai_knowledge_base_plain_text.assert_awaited_once_with( + "pipe-uuid-1", + name="Onboarding", + content="Step 1...", + description="How to onboard", + ) + + +def test_kb_plain_text_create_denied_probe_blocks_write( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=DENIED_PROBE) + mock_client.create_ai_knowledge_base_plain_text = AsyncMock() + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "create", + "--pipe-uuid", + "pipe-uuid-1", + "--name", + "n", + "--content", + "c", + "--description", + "d", + "--json", + ], + ) + assert result.exit_code == 1 + assert json.loads(result.stdout)["success"] is False + mock_client.create_ai_knowledge_base_plain_text.assert_not_awaited() + + +def test_kb_plain_text_update_partial(runner, clean_pipefy_env, saved_cwd, monkeypatch): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=GREEN_PROBE) + mock_client.update_ai_knowledge_base_plain_text = AsyncMock( + return_value=PLAIN_TEXT_FULL + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "update", + "--id", + "kb-1", + "--pipe-uuid", + "pipe-uuid-1", + "--content", + "New content", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + mock_client.update_ai_knowledge_base_plain_text.assert_awaited_once_with( + "kb-1", "pipe-uuid-1", name=None, content="New content", description=None + ) + + +def test_kb_plain_text_delete_with_yes( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.delete_ai_knowledge_base_plain_text = AsyncMock( + return_value={"success": True, "errors": []} + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "delete", + "--id", + "kb-1", + "--pipe-uuid", + "pipe-uuid-1", + "--yes", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + assert json.loads(result.stdout)["success"] is True + mock_client.delete_ai_knowledge_base_plain_text.assert_awaited_once_with( + "kb-1", "pipe-uuid-1" + ) + + +def test_kb_plain_text_delete_aborts_without_confirmation( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.delete_ai_knowledge_base_plain_text = AsyncMock() + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "delete", + "--id", + "kb-1", + "--pipe-uuid", + "pipe-uuid-1", + "--json", + ], + input="n\n", + ) + assert result.exit_code != 0 + mock_client.delete_ai_knowledge_base_plain_text.assert_not_awaited() 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 3a5b9409..5cb183b6 100644 --- a/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py @@ -581,6 +581,7 @@ async def validate_ai_agent_behaviors( pipe_id: PipefyId, behaviors: list[dict], strict_unknown_action_types: bool = True, + data_source_ids: list[str] | None = None, ) -> dict: """Dry-run validation of AI Agent behaviors against a pipe's fields, phases, and relations. @@ -638,6 +639,12 @@ async def validate_ai_agent_behaviors( ``fieldId`` values (slug or ``internal_id``). strict_unknown_action_types: When ``True`` (default), unknown ``actionType`` values are reported in ``problems``. When ``False``, they appear in ``warnings`` only. + data_source_ids: Optional agent-level knowledge base IDs to attach. These are + unioned with each behavior's ``actionParams.aiBehaviorParams.dataSourceIds`` + and checked against the pipe's knowledge bases (via ``get_ai_knowledge_bases``); + unknown IDs produce **warnings** only (``valid`` stays true). If the knowledge + base list cannot be read, a single warning is added and the membership check is + skipped. Discover valid IDs via ``get_ai_knowledge_bases(pipe_uuid)``. """ client = get_pipefy_client(ctx) pid = str(pipe_id).strip() @@ -653,6 +660,7 @@ async def validate_ai_agent_behaviors( pid, behaviors, strict_unknown_action_types=strict_unknown_action_types, + data_source_ids=data_source_ids, ) if not result.get("success"): probs = result.get("problems") or [] diff --git a/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py b/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py new file mode 100644 index 00000000..5ce6b88e --- /dev/null +++ b/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py @@ -0,0 +1,318 @@ +"""MCP tools for pipe-scoped AI knowledge bases (list, plain text CRUD, probe). + +Reads and writes reach the API with the request-scoped credential and are fully +governed by API permissions. Writes are gated on the caller running +``validate_knowledge_base_access`` first (explicit-validate-first): the tools do +not auto-probe inside create/update. Deletes require a two-step confirm. +""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations +from pipefy_sdk import classify_exception + +from pipefy_mcp.core.tool_error_envelope import ( + is_unified_envelope_enabled, + tool_error, + tool_success, +) +from pipefy_mcp.tools.destructive_tool_guard import check_destructive_confirmation +from pipefy_mcp.tools.tool_context import get_pipefy_client + +_KB_ID_DISCOVERY_HINT = ( + "Use 'get_ai_knowledge_bases' to list knowledge base IDs for the pipe." +) + + +def _kb_tool_error_from_exception(exc: BaseException) -> dict[str, Any]: + """Map an SDK/GraphQL exception onto the canonical tool failure envelope. + + Uses the shared SDK classifier so the kind/code the CLI and probe see is the + same reported here. A transport-level failure with no GraphQL errors falls + back to ``str(exc)``. + """ + problem = classify_exception(exc) + if problem is None: + return tool_error(str(exc)) + message = problem.message + if problem.kind.value == "not_found": + message = f"{message} {_KB_ID_DISCOVERY_HINT}" + details: dict[str, Any] = {"kind": problem.kind.value} + if problem.correlation_id: + details["correlation_id"] = problem.correlation_id + return tool_error(message, code=problem.code, details=details) + + +def _blank_error(value: str, field: str) -> dict[str, Any] | None: + if not value.strip(): + return tool_error(f"'{field}' must be non-empty.") + return None + + +def _kb_success(data: dict[str, Any], *, message: str) -> dict[str, Any]: + """Unified-envelope success (legacy flat payload when the flag is off).""" + if is_unified_envelope_enabled(): + return tool_success(data=data, message=message) + return {"success": True, **data} + + +class KnowledgeBaseTools: + """Declares MCP tools for pipe-scoped AI knowledge bases.""" + + @staticmethod + def register(mcp: FastMCP) -> None: + """Register knowledge base tools on the MCP server.""" + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def get_ai_knowledge_bases( + ctx: Context, + pipe_uuid: str, + ) -> dict[str, Any]: + """List every knowledge base item on a pipe: plain texts, documents, and data lookups in one surface. Use this to discover the `dataSourceIds` values an AI agent or behavior can attach. + + Each item carries `id` (the data-source ID used in `dataSourceIds`), + `type` (e.g. `knowledge_base_plain_texts`), `name`, `description`, and + `updatedAt`. There is no pagination; the full list is returned. + + Args: + pipe_uuid: Pipe UUID (not the numeric ID; `get_pipe` returns the `uuid` field). + """ + client = get_pipefy_client(ctx) + err = _blank_error(pipe_uuid, "pipe_uuid") + if err is not None: + return err + try: + items = await client.get_ai_knowledge_bases(pipe_uuid.strip()) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + return _kb_success( + {"knowledge_bases": items}, message="Knowledge bases retrieved." + ) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def get_ai_knowledge_base_plain_text( + ctx: Context, + plain_text_id: str, + pipe_uuid: str, + ) -> dict[str, Any]: + """Fetch one pipe-scoped knowledge base plain text by ID, including its full content. + + Args: + plain_text_id: Knowledge base plain text ID (from `get_ai_knowledge_bases`). + pipe_uuid: Pipe UUID (not the numeric ID). + """ + client = get_pipefy_client(ctx) + err = _blank_error(plain_text_id, "plain_text_id") or _blank_error( + pipe_uuid, "pipe_uuid" + ) + if err is not None: + return err + try: + plain_text = await client.get_ai_knowledge_base_plain_text( + plain_text_id.strip(), pipe_uuid.strip() + ) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + if not plain_text: + return tool_error( + f"Knowledge base plain text not found: {plain_text_id.strip()}. " + f"{_KB_ID_DISCOVERY_HINT}" + ) + return _kb_success( + {"knowledge_base_plain_text": plain_text}, + message="Knowledge base plain text retrieved.", + ) + + @mcp.tool( + annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False), + ) + async def create_ai_knowledge_base_plain_text( + ctx: Context, + pipe_uuid: str, + name: str, + content: str, + description: str, + ) -> dict[str, Any]: + """Create a pipe-scoped knowledge base plain text. Requires manage_ai_agents on the pipe; run `validate_knowledge_base_access` first to confirm access. + + Limits are enforced client-side to fail fast: `content` is 1-3500 + characters and `description` is 1-900 characters (both required). To + attach the result to an agent, pass the returned `id` in the agent's or + behavior's `dataSourceIds`. + + Args: + pipe_uuid: Pipe UUID (not the numeric ID). + name: Display name (required, non-blank). + content: Plain text content (required, 1-3500 characters). + description: Description (required, 1-900 characters). + """ + client = get_pipefy_client(ctx) + err = _blank_error(pipe_uuid, "pipe_uuid") + if err is not None: + return err + try: + plain_text = await client.create_ai_knowledge_base_plain_text( + pipe_uuid.strip(), + name=name, + content=content, + description=description, + ) + except ValueError as exc: + return tool_error(str(exc)) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + return _kb_success( + {"knowledge_base_plain_text": plain_text}, + message="Knowledge base plain text created.", + ) + + @mcp.tool( + annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False), + ) + async def update_ai_knowledge_base_plain_text( + ctx: Context, + plain_text_id: str, + pipe_uuid: str, + name: str | None = None, + content: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Update a pipe-scoped knowledge base plain text. Requires manage_ai_agents; run `validate_knowledge_base_access` first. + + Partial update: only the fields you pass change; omitted fields keep + their stored value. Provide at least one of `name`, `content`, or + `description`. When given, `content` is 1-3500 characters and + `description` is 1-900 characters (limits enforced client-side). + + Args: + plain_text_id: Plain text ID to update (from `get_ai_knowledge_bases`). + pipe_uuid: Pipe UUID (not the numeric ID). + name: New name (non-blank when given). + content: New content (1-3500 characters when given). + description: New description (1-900 characters when given). + """ + client = get_pipefy_client(ctx) + err = _blank_error(plain_text_id, "plain_text_id") or _blank_error( + pipe_uuid, "pipe_uuid" + ) + if err is not None: + return err + try: + plain_text = await client.update_ai_knowledge_base_plain_text( + plain_text_id.strip(), + pipe_uuid.strip(), + name=name, + content=content, + description=description, + ) + except ValueError as exc: + return tool_error(str(exc)) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + return _kb_success( + {"knowledge_base_plain_text": plain_text}, + message="Knowledge base plain text updated.", + ) + + @mcp.tool( + annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True), + ) + async def delete_ai_knowledge_base_plain_text( + ctx: Context, + plain_text_id: str, + pipe_uuid: str, + confirm: bool = False, + ) -> dict[str, Any]: + """Delete a pipe-scoped knowledge base plain text permanently. This action is irreversible. + + Two-step operation: preview with `confirm=False` (default), then execute + with `confirm=True` after explicit human approval. Elicitation does not + authorize deletion (only `confirm=True` does). Requires manage_ai_agents + on the pipe. + + Args: + plain_text_id: Plain text ID to delete (from `get_ai_knowledge_bases`). + pipe_uuid: Pipe UUID (not the numeric ID). + confirm: Must be `True` to run the delete mutation. + """ + client = get_pipefy_client(ctx) + err = _blank_error(plain_text_id, "plain_text_id") or _blank_error( + pipe_uuid, "pipe_uuid" + ) + if err is not None: + return err + + guard = await check_destructive_confirmation( + ctx, + confirm=confirm, + resource_descriptor=( + f"knowledge base plain text (ID: {plain_text_id.strip()})" + ), + ) + if guard is not None: + return guard + + try: + result = await client.delete_ai_knowledge_base_plain_text( + plain_text_id.strip(), pipe_uuid.strip() + ) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + if not result.get("success"): + errs = result.get("errors") or [] + detail = ( + "; ".join(str(e) for e in errs) + if errs + else "API returned success=false" + ) + return tool_error( + f"delete_ai_knowledge_base_plain_text failed: {detail}" + ) + return _kb_success( + {"deleted_id": plain_text_id.strip()}, + message="Knowledge base plain text deleted.", + ) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def validate_knowledge_base_access( + ctx: Context, + pipe_uuid: str, + ) -> dict[str, Any]: + """Probe whether the current credential can read a pipe's knowledge bases. A green result proves READ access only (read_ai_agents), never the manage_ai_agents entitlement plain-text create/update/delete require. + + On success, reports how many knowledge base items are visible. On a + classified failure, returns the structured problem (permission denied / + not found / invalid arguments) instead of an opaque error. Run this + before knowledge base writes to confirm access. + + Args: + pipe_uuid: Pipe UUID (not the numeric ID). + """ + client = get_pipefy_client(ctx) + err = _blank_error(pipe_uuid, "pipe_uuid") + if err is not None: + return err + try: + probe = await client.validate_knowledge_base_access(pipe_uuid.strip()) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + if probe.get("ok"): + return _kb_success( + dict(probe), message="Knowledge base read access confirmed." + ) + problem = probe.get("problem") or {} + return tool_error( + str(problem.get("message") or "Knowledge base access probe failed."), + code=problem.get("code"), + details={ + k: v + for k, v in ( + ("kind", problem.get("kind")), + ("correlation_id", problem.get("correlation_id")), + ) + if v + }, + ) diff --git a/packages/mcp/src/pipefy_mcp/tools/registry.py b/packages/mcp/src/pipefy_mcp/tools/registry.py index 9cd7d740..51d03134 100644 --- a/packages/mcp/src/pipefy_mcp/tools/registry.py +++ b/packages/mcp/src/pipefy_mcp/tools/registry.py @@ -14,6 +14,7 @@ from pipefy_mcp.tools.field_condition_tools import FieldConditionTools from pipefy_mcp.tools.introspection_tools import IntrospectionTools from pipefy_mcp.tools.ipaas_tools import IpaasTools +from pipefy_mcp.tools.knowledge_base_tools import KnowledgeBaseTools from pipefy_mcp.tools.llm_provider_tools import LlmProviderTools from pipefy_mcp.tools.member_tools import MemberTools from pipefy_mcp.tools.observability_tools import ObservabilityTools @@ -43,6 +44,7 @@ "create_automation", "create_card", "create_card_relation", + "create_ai_knowledge_base_plain_text", "create_field_condition", "create_ipaas_connection", "create_label", @@ -63,6 +65,7 @@ "create_webhook", "delete_ai_agent", "delete_ai_automation", + "delete_ai_knowledge_base_plain_text", "delete_automation", "delete_card", "delete_card_relation", @@ -101,6 +104,8 @@ "get_ai_automation", "get_ai_automations", "get_ai_credit_usage", + "get_ai_knowledge_base_plain_text", + "get_ai_knowledge_bases", "get_automation", "get_automation_actions", "get_automation_event_attributes", @@ -171,6 +176,7 @@ "unpublish_sub_portal", "update_ai_agent", "update_ai_automation", + "update_ai_knowledge_base_plain_text", "update_automation", "update_card", "update_card_field", @@ -196,6 +202,7 @@ "upload_attachment_to_table_record", "validate_ai_agent_behaviors", "validate_ai_automation_prompt", + "validate_knowledge_base_access", "validate_llm_provider_access", } ) @@ -222,6 +229,7 @@ AiAutomationTools, AiAgentTools, LlmProviderTools, + KnowledgeBaseTools, ) diff --git a/packages/mcp/tests/tools/test_knowledge_base_tools.py b/packages/mcp/tests/tools/test_knowledge_base_tools.py new file mode 100644 index 00000000..ba585e00 --- /dev/null +++ b/packages/mcp/tests/tools/test_knowledge_base_tools.py @@ -0,0 +1,326 @@ +"""Tests for AI knowledge base MCP tools (mocked PipefyClient).""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from gql.transport.exceptions import TransportQueryError +from mcp.shared.memory import ( + create_connected_server_and_client_session as create_client_session, +) +from pipefy_sdk import PipefyClient + +from pipefy_mcp.core.tool_error_envelope import tool_error_message +from pipefy_mcp.tools.knowledge_base_tools import KnowledgeBaseTools +from tools.conftest import build_tool_test_server + +PLAIN_TEXT_NODE = { + "id": "kb-1", + "type": "knowledge_base_plain_texts", + "name": "Onboarding", + "description": "How to onboard", + "updatedAt": "2026-07-17T00:00:00Z", +} + +PLAIN_TEXT_FULL = { + "id": "kb-1", + "name": "Onboarding", + "description": "How to onboard", + "content": "Step 1...", + "updatedAt": "2026-07-17T00:00:00Z", +} + + +def permission_denied_error() -> TransportQueryError: + return TransportQueryError( + "denied", + errors=[ + { + "message": "Permission denied", + "extensions": {"code": "PERMISSION_DENIED", "correlation_id": "corr-9"}, + } + ], + ) + + +def not_found_error() -> TransportQueryError: + return TransportQueryError( + "missing", + errors=[ + { + "message": "Couldn't find Pipe with uuid bogus", + "extensions": {"code": "RESOURCE_NOT_FOUND"}, + } + ], + ) + + +@pytest.fixture +def mock_kb_client(): + client = MagicMock(PipefyClient) + client.get_ai_knowledge_bases = AsyncMock() + client.get_ai_knowledge_base_plain_text = AsyncMock() + client.create_ai_knowledge_base_plain_text = AsyncMock() + client.update_ai_knowledge_base_plain_text = AsyncMock() + client.delete_ai_knowledge_base_plain_text = AsyncMock() + client.validate_knowledge_base_access = AsyncMock() + return client + + +@pytest.fixture +def kb_mcp_server(mock_kb_client): + return build_tool_test_server( + "Pipefy Knowledge Base Tools Test", + KnowledgeBaseTools.register, + mock_kb_client, + ) + + +@pytest.fixture +def kb_session(kb_mcp_server, request): + elicitation = getattr(request, "param", None) + return create_client_session( + kb_mcp_server, + read_timeout_seconds=timedelta(seconds=10), + raise_exceptions=True, + elicitation_callback=elicitation, + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_knowledge_bases_success( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.get_ai_knowledge_bases = AsyncMock(return_value=[PLAIN_TEXT_NODE]) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_bases", {"pipe_uuid": "pipe-uuid-1"} + ) + assert result.isError is False + mock_kb_client.get_ai_knowledge_bases.assert_awaited_once_with("pipe-uuid-1") + payload = extract_payload(result) + assert payload["success"] is True + assert payload["data"]["knowledge_bases"] == [PLAIN_TEXT_NODE] + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_knowledge_bases_blank_pipe_uuid_rejected( + kb_session, mock_kb_client, extract_payload +): + async with kb_session as session: + result = await session.call_tool("get_ai_knowledge_bases", {"pipe_uuid": " "}) + payload = extract_payload(result) + assert payload["success"] is False + assert "pipe_uuid" in tool_error_message(payload) + mock_kb_client.get_ai_knowledge_bases.assert_not_awaited() + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_plain_text_not_found( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.get_ai_knowledge_base_plain_text = AsyncMock(return_value={}) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_base_plain_text", + {"plain_text_id": "kb-x", "pipe_uuid": "pipe-uuid-1"}, + ) + payload = extract_payload(result) + assert payload["success"] is False + assert "not found" in tool_error_message(payload).lower() + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_create_plain_text_success( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.create_ai_knowledge_base_plain_text = AsyncMock( + return_value=PLAIN_TEXT_FULL + ) + async with kb_session as session: + result = await session.call_tool( + "create_ai_knowledge_base_plain_text", + { + "pipe_uuid": "pipe-uuid-1", + "name": "Onboarding", + "content": "Step 1...", + "description": "How to onboard", + }, + ) + assert result.isError is False + payload = extract_payload(result) + assert payload["success"] is True + assert payload["data"]["knowledge_base_plain_text"] == PLAIN_TEXT_FULL + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_create_plain_text_limit_value_error_mapped( + kb_session, mock_kb_client, extract_payload +): + mock_kb_client.create_ai_knowledge_base_plain_text = AsyncMock( + side_effect=ValueError("content must be at most 3500 characters (got 3501)") + ) + async with kb_session as session: + result = await session.call_tool( + "create_ai_knowledge_base_plain_text", + { + "pipe_uuid": "pipe-uuid-1", + "name": "n", + "content": "x", + "description": "d", + }, + ) + payload = extract_payload(result) + assert payload["success"] is False + assert "3500" in tool_error_message(payload) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_update_plain_text_success( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.update_ai_knowledge_base_plain_text = AsyncMock( + return_value=PLAIN_TEXT_FULL + ) + async with kb_session as session: + result = await session.call_tool( + "update_ai_knowledge_base_plain_text", + { + "plain_text_id": "kb-1", + "pipe_uuid": "pipe-uuid-1", + "content": "New content", + }, + ) + assert result.isError is False + mock_kb_client.update_ai_knowledge_base_plain_text.assert_awaited_once_with( + "kb-1", "pipe-uuid-1", name=None, content="New content", description=None + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_delete_preview_without_confirm_does_not_delete( + kb_session, mock_kb_client, extract_payload +): + async with kb_session as session: + result = await session.call_tool( + "delete_ai_knowledge_base_plain_text", + {"plain_text_id": "kb-1", "pipe_uuid": "pipe-uuid-1"}, + ) + payload = extract_payload(result) + assert payload["success"] is False + assert payload["requires_confirmation"] is True + mock_kb_client.delete_ai_knowledge_base_plain_text.assert_not_awaited() + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_delete_with_confirm_executes( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.delete_ai_knowledge_base_plain_text = AsyncMock( + return_value={"success": True, "errors": []} + ) + async with kb_session as session: + result = await session.call_tool( + "delete_ai_knowledge_base_plain_text", + {"plain_text_id": "kb-1", "pipe_uuid": "pipe-uuid-1", "confirm": True}, + ) + assert result.isError is False + payload = extract_payload(result) + assert payload["success"] is True + mock_kb_client.delete_ai_knowledge_base_plain_text.assert_awaited_once_with( + "kb-1", "pipe-uuid-1" + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_validate_access_green( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.validate_knowledge_base_access = AsyncMock( + return_value={ + "ok": True, + "knowledge_base_count": 2, + "note": "Read access confirmed.", + } + ) + async with kb_session as session: + result = await session.call_tool( + "validate_knowledge_base_access", {"pipe_uuid": "pipe-uuid-1"} + ) + assert result.isError is False + payload = extract_payload(result) + assert payload["success"] is True + assert payload["data"]["knowledge_base_count"] == 2 + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_validate_access_failure_classified( + kb_session, mock_kb_client, extract_payload +): + mock_kb_client.validate_knowledge_base_access = AsyncMock( + return_value={ + "ok": False, + "problem": { + "kind": "permission_denied", + "message": "Permission denied", + "code": "PERMISSION_DENIED", + "correlation_id": "corr-9", + }, + } + ) + async with kb_session as session: + result = await session.call_tool( + "validate_knowledge_base_access", {"pipe_uuid": "pipe-uuid-1"} + ) + payload = extract_payload(result) + assert payload["success"] is False + assert payload["error"]["code"] == "PERMISSION_DENIED" + assert payload["error"]["details"]["kind"] == "permission_denied" + assert payload["error"]["details"]["correlation_id"] == "corr-9" + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_knowledge_bases_permission_denied_classified( + kb_session, mock_kb_client, extract_payload +): + mock_kb_client.get_ai_knowledge_bases = AsyncMock( + side_effect=permission_denied_error() + ) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_bases", {"pipe_uuid": "pipe-uuid-1"} + ) + payload = extract_payload(result) + assert payload["success"] is False + assert payload["error"]["code"] == "PERMISSION_DENIED" + assert payload["error"]["details"]["correlation_id"] == "corr-9" + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_plain_text_not_found_error_adds_discovery_hint( + kb_session, mock_kb_client, extract_payload +): + mock_kb_client.get_ai_knowledge_base_plain_text = AsyncMock( + side_effect=not_found_error() + ) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_base_plain_text", + {"plain_text_id": "kb-x", "pipe_uuid": "pipe-uuid-1"}, + ) + payload = extract_payload(result) + assert payload["success"] is False + assert payload["error"]["details"]["kind"] == "not_found" + assert "get_ai_knowledge_bases" in tool_error_message(payload) diff --git a/packages/sdk/src/pipefy_sdk/__init__.py b/packages/sdk/src/pipefy_sdk/__init__.py index 5d3ff4db..77fe47ba 100644 --- a/packages/sdk/src/pipefy_sdk/__init__.py +++ b/packages/sdk/src/pipefy_sdk/__init__.py @@ -70,6 +70,10 @@ from pipefy_sdk.services.types import ( AiAgentGraphPayload, CardSearch, + KnowledgeBaseAccessProbeResult, + KnowledgeBaseDeleteResult, + KnowledgeBasePayload, + KnowledgeBasePlainTextPayload, LlmProviderPayload, LlmProvidersResult, MePayload, @@ -112,6 +116,10 @@ "DeleteCommentInput", "GraphQLProblem", "GraphQLProblemKind", + "KnowledgeBaseAccessProbeResult", + "KnowledgeBaseDeleteResult", + "KnowledgeBasePayload", + "KnowledgeBasePlainTextPayload", "LlmProviderPayload", "LlmProvidersResult", "ProviderAccessProbeResult", diff --git a/packages/sdk/src/pipefy_sdk/ai_preflight.py b/packages/sdk/src/pipefy_sdk/ai_preflight.py index 542baaaa..5078657a 100644 --- a/packages/sdk/src/pipefy_sdk/ai_preflight.py +++ b/packages/sdk/src/pipefy_sdk/ai_preflight.py @@ -241,12 +241,93 @@ def _targets_name_field(err: dict[str, Any]) -> bool: return problems if problems else [str(exc)] +def _behavior_data_source_ids(behavior: dict[str, Any]) -> list[str]: + """Extract ``actionParams.aiBehaviorParams.dataSourceIds`` from a behavior dict. + + Accepts both camelCase and snake_case keys (behaviors arrive in either shape). + """ + action_params = behavior.get("actionParams") or behavior.get("action_params") + if not isinstance(action_params, dict): + return [] + ai_params = action_params.get("aiBehaviorParams") or action_params.get( + "ai_behavior_params" + ) + if not isinstance(ai_params, dict): + return [] + ids = ai_params.get("dataSourceIds") or ai_params.get("data_source_ids") + if not isinstance(ids, list): + return [] + return [stripped for i in ids if (stripped := str(i).strip())] + + +def _collect_configured_data_source_ids( + behaviors: list[dict[str, Any]], + agent_data_source_ids: list[str] | None, +) -> set[str]: + """Union of agent-level and behavior-level configured data source ids.""" + configured = { + stripped + for did in agent_data_source_ids or [] + if (stripped := str(did).strip()) + } + for behavior in behaviors: + configured.update(_behavior_data_source_ids(behavior)) + return configured + + +async def _data_source_membership_warnings( + client: PipefyClient, + pipe_id: str, + configured_ids: set[str], +) -> list[str]: + """Warn (never block) for configured data source ids not on the pipe. + + The knowledge base list is pipe-UUID-scoped, so this resolves the pipe UUID + first. A failed list probe (permission, feature, transport) yields a single + warning and skips every per-id membership claim, so an inability to read the + knowledge base is never reported as a broken reference. + """ + try: + pipe_data = await client.get_pipe(pipe_id) + except Exception as exc: # noqa: BLE001 + logger.debug("data-source membership: pipe fetch failed: %s", exc) + return [ + f"Could not verify data source membership: failed to load pipe {pipe_id}." + ] + pipe_uuid = (pipe_data.get("pipe") or {}).get("uuid") + if not pipe_uuid: + return [ + "Could not verify data source membership: pipe " + f"{pipe_id} has no uuid in the response." + ] + try: + knowledge_bases = await client.get_ai_knowledge_bases(str(pipe_uuid)) + except Exception as exc: # noqa: BLE001 + logger.debug("data-source membership: kb list failed: %s", exc) + return [ + "Could not verify data source membership: knowledge base list " + f"probe failed ({exc})." + ] + known_ids = { + str(kb.get("id")) + for kb in knowledge_bases + if isinstance(kb, dict) and kb.get("id") + } + missing = sorted(cid for cid in configured_ids if cid not in known_ids) + return [ + f"data_source id '{cid}' is not a knowledge base of pipe {pipe_id}; " + "attaching it may fail. Verify with get_ai_knowledge_bases." + for cid in missing + ] + + async def validate_ai_agent_behaviors_sdk( client: PipefyClient, pipe_id: str, behaviors: list[dict[str, Any]], *, strict_unknown_action_types: bool = True, + data_source_ids: list[str] | None = None, ) -> dict[str, Any]: """Mirror MCP ``validate_ai_agent_behaviors`` (read-only). @@ -255,6 +336,11 @@ async def validate_ai_agent_behaviors_sdk( pipe_id: Numeric pipe id for field/phase/relation context. behaviors: Raw behavior dicts (1-5). strict_unknown_action_types: When False, unknown action types become warnings only. + data_source_ids: Optional agent-level knowledge base ids. These are unioned + with each behavior's ``actionParams.aiBehaviorParams.dataSourceIds`` and + checked against the pipe's knowledge bases; unknown ids yield warnings + only (``valid`` stays true). A failed list probe skips the membership + check with a single warning. """ pid = str(pipe_id).strip() if not pid: @@ -403,6 +489,17 @@ async def validate_ai_agent_behaviors_sdk( problems = [*problems, *transition_problems] warnings = [*tool_warnings, *helper_warnings] + configured_data_source_ids = _collect_configured_data_source_ids( + behaviors, data_source_ids + ) + if configured_data_source_ids: + warnings = [ + *warnings, + *await _data_source_membership_warnings( + client, pid, configured_data_source_ids + ), + ] + if problems: msg = f"Found {len(problems)} problem(s) in behaviors." elif warnings: diff --git a/packages/sdk/src/pipefy_sdk/client.py b/packages/sdk/src/pipefy_sdk/client.py index 97d7bd44..6a1ceb9d 100644 --- a/packages/sdk/src/pipefy_sdk/client.py +++ b/packages/sdk/src/pipefy_sdk/client.py @@ -50,6 +50,7 @@ ) from pipefy_sdk.services.automation_service import AutomationService from pipefy_sdk.services.card_service import CardService +from pipefy_sdk.services.knowledge_base_service import KnowledgeBaseService from pipefy_sdk.services.llm_provider_service import ( DEFAULT_PROVIDER_PAGE_SIZE, LlmProviderService, @@ -80,6 +81,10 @@ AiAgentGraphPayload, AutomationServiceResult, CardSearch, + KnowledgeBaseAccessProbeResult, + KnowledgeBaseDeleteResult, + KnowledgeBasePayload, + KnowledgeBasePlainTextPayload, LlmProviderPayload, LlmProvidersResult, MePayload, @@ -277,6 +282,7 @@ def _wire(self, ex: Executors, settings: PipefySettings) -> None: self._automation_service = AutomationService(executor=ex.public) self._ai_agent_service = AiAgentService(executor=ex.public) self._llm_provider_service = LlmProviderService(executor=ex.public) + self._knowledge_base_service = KnowledgeBaseService(executor=ex.public) self._observability_service = ObservabilityService(executor=ex.public) self._report_service = ReportService(executor=ex.public) self._organization_service = OrganizationService(executor=ex.public) @@ -965,6 +971,67 @@ async def validate_llm_provider_access( organization_uuid ) + async def get_ai_knowledge_bases( + self, pipe_uuid: str + ) -> list[KnowledgeBasePayload]: + """List every knowledge base item on a pipe (plain text, docs, lookups).""" + return await self._knowledge_base_service.get_ai_knowledge_bases(pipe_uuid) + + async def get_ai_knowledge_base_plain_text( + self, plain_text_id: str, pipe_uuid: str + ) -> KnowledgeBasePlainTextPayload: + """Fetch one pipe-scoped knowledge base plain text by id.""" + return await self._knowledge_base_service.get_ai_knowledge_base_plain_text( + plain_text_id, pipe_uuid + ) + + async def create_ai_knowledge_base_plain_text( + self, + pipe_uuid: str, + *, + name: str, + content: str, + description: str, + ) -> KnowledgeBasePlainTextPayload: + """Create a pipe-scoped knowledge base plain text (limits enforced client-side).""" + return await self._knowledge_base_service.create_ai_knowledge_base_plain_text( + pipe_uuid, name=name, content=content, description=description + ) + + async def update_ai_knowledge_base_plain_text( + self, + plain_text_id: str, + pipe_uuid: str, + *, + name: str | None = None, + content: str | None = None, + description: str | None = None, + ) -> KnowledgeBasePlainTextPayload: + """Update a pipe-scoped knowledge base plain text (partial; validates given fields).""" + return await self._knowledge_base_service.update_ai_knowledge_base_plain_text( + plain_text_id, + pipe_uuid, + name=name, + content=content, + description=description, + ) + + async def delete_ai_knowledge_base_plain_text( + self, plain_text_id: str, pipe_uuid: str + ) -> KnowledgeBaseDeleteResult: + """Delete a pipe-scoped knowledge base plain text (permanent).""" + return await self._knowledge_base_service.delete_ai_knowledge_base_plain_text( + plain_text_id, pipe_uuid + ) + + async def validate_knowledge_base_access( + self, pipe_uuid: str + ) -> KnowledgeBaseAccessProbeResult: + """Probe knowledge-base read access for a pipe; classifies errors instead of raising.""" + return await self._knowledge_base_service.validate_knowledge_base_access( + pipe_uuid + ) + async def create_ai_agent( self, agent_input: CreateAiAgentInput ) -> AgentServiceResult: diff --git a/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py b/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py new file mode 100644 index 00000000..a2363d5f --- /dev/null +++ b/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py @@ -0,0 +1,100 @@ +"""GraphQL operations for pipe-scoped AI knowledge bases (list + plain text CRUD). + +All operations are pipe-scoped by ``pipeUuid`` (the pipe UUID, not the numeric +id). The list query returns every knowledge base item on a pipe (plain text, +documents, data lookups) as a plain ``[AiKnowledgeBase]`` list — there is no +Relay connection and no pagination. Plain-text create/update/delete are Relay +mutations that take a single ``input`` object. +""" + +from __future__ import annotations + +from gql import gql + +# aiKnowledgeBases(pipeUuid) returns a plain list (not a connection). Each node +# is one data source; `type` is the JSON:API resource type (e.g. +# `knowledge_base_plain_texts`, `knowledge_base_documents`, `data_lookups`). +GET_AI_KNOWLEDGE_BASES_QUERY = gql( + """ + query aiKnowledgeBases($pipeUuid: ID!) { + aiKnowledgeBases(pipeUuid: $pipeUuid) { + id + type + name + description + updatedAt + } + } + """ +) + +GET_AI_KNOWLEDGE_BASE_PLAIN_TEXT_QUERY = gql( + """ + query aiKnowledgeBasePlainText($id: ID!, $pipeUuid: ID!) { + aiKnowledgeBasePlainText(id: $id, pipeUuid: $pipeUuid) { + id + name + description + content + updatedAt + } + } + """ +) + +CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION = gql( + """ + mutation createAiKnowledgeBasePlainText( + $input: CreateKnowledgeBasePlainTextInput! + ) { + createAiKnowledgeBasePlainText(input: $input) { + knowledgeBasePlainText { + id + name + description + content + updatedAt + } + } + } + """ +) + +UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION = gql( + """ + mutation updateAiKnowledgeBasePlainText( + $input: UpdateKnowledgeBasePlainTextInput! + ) { + updateAiKnowledgeBasePlainText(input: $input) { + knowledgeBasePlainText { + id + name + description + content + updatedAt + } + } + } + """ +) + +DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION = gql( + """ + mutation deleteAiKnowledgeBasePlainText( + $input: DeleteKnowledgeBasePlainTextInput! + ) { + deleteAiKnowledgeBasePlainText(input: $input) { + success + errors + } + } + """ +) + +__all__ = [ + "CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION", + "DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION", + "GET_AI_KNOWLEDGE_BASES_QUERY", + "GET_AI_KNOWLEDGE_BASE_PLAIN_TEXT_QUERY", + "UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION", +] diff --git a/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py b/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py new file mode 100644 index 00000000..517d5b3c --- /dev/null +++ b/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py @@ -0,0 +1,278 @@ +"""Service for pipe-scoped AI knowledge bases: list, plain text CRUD, and probe. + +Client-side limits fail fast before the network call so callers get an +actionable ``ValueError`` instead of a backend 422. The limits mirror the +downstream ``DataSource``/``KnowledgeBasePlainText`` model validations: +``content`` and ``name`` are required and ``content`` is capped at 3500 chars; +``description`` is required (the GraphQL schema marks it optional, but the +backend rejects a blank one) and capped at 900 chars. Updates omit any field the +caller leaves unset, but validate every field they do pass. +""" + +from __future__ import annotations + +from typing import Any + +from pipefy_sdk.graphql_executor import GraphQLExecutor +from pipefy_sdk.graphql_problem import ( + GraphQLProblem, + GraphQLProblemKind, + classify_graphql_error_dicts, +) +from pipefy_sdk.queries.knowledge_base_queries import ( + CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, + DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, + GET_AI_KNOWLEDGE_BASE_PLAIN_TEXT_QUERY, + GET_AI_KNOWLEDGE_BASES_QUERY, + UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, +) +from pipefy_sdk.services.types import ( + KnowledgeBaseAccessProbeResult, + KnowledgeBaseDeleteResult, + KnowledgeBasePayload, + KnowledgeBasePlainTextPayload, +) + +MAX_PLAIN_TEXT_CONTENT_LENGTH = 3500 +MAX_PLAIN_TEXT_DESCRIPTION_LENGTH = 900 + +_PROBE_READ_ONLY_NOTE = ( + "Read access confirmed. This proves knowledge-base list/read access only " + "(read_ai_agents on the pipe), never write entitlement; plain-text " + "create/update/delete require manage_ai_agents and may still be denied." +) + + +def _require_non_blank(value: str, name: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError(f"{name} must be a non-empty string") + return stripped + + +def _require_bounded(value: str, name: str, max_length: int) -> str: + """Require a non-blank, length-capped field (content/description on write).""" + stripped = value.strip() + if not stripped: + raise ValueError(f"{name} must be a non-empty string") + if len(stripped) > max_length: + raise ValueError( + f"{name} must be at most {max_length} characters (got {len(stripped)})" + ) + return stripped + + +def _problem_dict(problem: GraphQLProblem) -> dict[str, Any]: + """Project a classified problem onto the probe's plain-dict shape.""" + return { + "kind": problem.kind.value, + "message": problem.message, + "code": problem.code, + "correlation_id": problem.correlation_id, + } + + +class KnowledgeBaseService: + """Pipe-scoped AI knowledge base reads, plain-text writes, and the probe.""" + + def __init__(self, *, executor: GraphQLExecutor) -> None: + self._executor = executor + + async def get_ai_knowledge_bases( + self, pipe_uuid: str + ) -> list[KnowledgeBasePayload]: + """List every knowledge base item on a pipe (plain text, docs, lookups). + + Args: + pipe_uuid: Pipe UUID (not the numeric id). + + Returns: + The pipe's knowledge base items; empty list when the pipe has none. + """ + variables = {"pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid")} + response = await self._executor.execute_query( + GET_AI_KNOWLEDGE_BASES_QUERY, variables + ) + items = response.get("aiKnowledgeBases") + return list(items) if isinstance(items, list) else [] + + async def get_ai_knowledge_base_plain_text( + self, plain_text_id: str, pipe_uuid: str + ) -> KnowledgeBasePlainTextPayload: + """Fetch one pipe-scoped knowledge base plain text by id. + + Args: + plain_text_id: Knowledge base plain text UUID (from the list). + pipe_uuid: Pipe UUID (not the numeric id). + + Returns: + The plain text dict; empty dict when the API resolves nothing. + """ + variables = { + "id": _require_non_blank(plain_text_id, "plain_text_id"), + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + } + response = await self._executor.execute_query( + GET_AI_KNOWLEDGE_BASE_PLAIN_TEXT_QUERY, variables + ) + plain_text = response.get("aiKnowledgeBasePlainText") + return plain_text if isinstance(plain_text, dict) else {} + + async def create_ai_knowledge_base_plain_text( + self, + pipe_uuid: str, + *, + name: str, + content: str, + description: str, + ) -> KnowledgeBasePlainTextPayload: + """Create a pipe-scoped knowledge base plain text. + + Args: + pipe_uuid: Pipe UUID (not the numeric id). + name: Display name (required, non-blank). + content: Plain text content (required, 1-3500 chars). + description: Description (required by the backend, 1-900 chars). + + Returns: + The created plain text dict. + """ + input_obj = { + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + "name": _require_non_blank(name, "name"), + "content": _require_bounded( + content, "content", MAX_PLAIN_TEXT_CONTENT_LENGTH + ), + "description": _require_bounded( + description, "description", MAX_PLAIN_TEXT_DESCRIPTION_LENGTH + ), + } + response = await self._executor.execute_query( + CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, {"input": input_obj} + ) + return _unwrap_plain_text(response, "createAiKnowledgeBasePlainText") + + async def update_ai_knowledge_base_plain_text( + self, + plain_text_id: str, + pipe_uuid: str, + *, + name: str | None = None, + content: str | None = None, + description: str | None = None, + ) -> KnowledgeBasePlainTextPayload: + """Update a pipe-scoped knowledge base plain text (partial). + + Only the fields you pass are sent; unset fields keep their stored value. + At least one of ``name``/``content``/``description`` must be provided. + + Args: + plain_text_id: Plain text UUID to update. + pipe_uuid: Pipe UUID (not the numeric id). + name: New name (non-blank when given). + content: New content (1-3500 chars when given). + description: New description (1-900 chars when given). + + Returns: + The updated plain text dict. + """ + if name is None and content is None and description is None: + raise ValueError( + "Provide at least one of name, content, or description to update." + ) + input_obj: dict[str, Any] = { + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + "plainTextId": _require_non_blank(plain_text_id, "plain_text_id"), + } + if name is not None: + input_obj["name"] = _require_non_blank(name, "name") + if content is not None: + input_obj["content"] = _require_bounded( + content, "content", MAX_PLAIN_TEXT_CONTENT_LENGTH + ) + if description is not None: + input_obj["description"] = _require_bounded( + description, "description", MAX_PLAIN_TEXT_DESCRIPTION_LENGTH + ) + response = await self._executor.execute_query( + UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, {"input": input_obj} + ) + return _unwrap_plain_text(response, "updateAiKnowledgeBasePlainText") + + async def delete_ai_knowledge_base_plain_text( + self, plain_text_id: str, pipe_uuid: str + ) -> KnowledgeBaseDeleteResult: + """Delete a pipe-scoped knowledge base plain text (permanent). + + Args: + plain_text_id: Plain text UUID to delete. + pipe_uuid: Pipe UUID (not the numeric id). + + Returns: + ``success`` and any backend ``errors``. + """ + input_obj = { + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + "plainTextId": _require_non_blank(plain_text_id, "plain_text_id"), + } + response = await self._executor.execute_query( + DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, {"input": input_obj} + ) + payload = response.get("deleteAiKnowledgeBasePlainText") + payload = payload if isinstance(payload, dict) else {} + errors = payload.get("errors") + return { + "success": bool(payload.get("success")), + "errors": [str(e) for e in errors] if isinstance(errors, list) else [], + } + + async def validate_knowledge_base_access( + self, pipe_uuid: str + ) -> KnowledgeBaseAccessProbeResult: + """Probe knowledge-base *read* access for a pipe. + + Runs the list query through the partial-success executor and classifies + any GraphQL errors instead of raising. A green result proves read access + only (``read_ai_agents``), never the ``manage_ai_agents`` entitlement the + plain-text writes require — spelled out in ``note``. + """ + variables = {"pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid")} + result = await self._executor.execute(GET_AI_KNOWLEDGE_BASES_QUERY, variables) + items = result.data.get("aiKnowledgeBases") + if items is None: + problem = classify_graphql_error_dicts(result.errors) + if problem is None: + problem_dict: dict[str, Any] = { + "kind": GraphQLProblemKind.RUNTIME.value, + "message": "Query returned no data and no errors.", + } + else: + problem_dict = _problem_dict(problem) + return {"ok": False, "problem": problem_dict} + + probe: KnowledgeBaseAccessProbeResult = { + "ok": True, + "knowledge_base_count": len(items), + "note": _PROBE_READ_ONLY_NOTE, + } + # A response can carry readable data alongside per-node errors; a green + # probe still surfaces them so partial denial is never read as full access. + partial = classify_graphql_error_dicts(result.errors) + if partial is not None: + probe["note"] = ( + f"{probe['note']} The response also carried GraphQL errors; " + "see problem." + ) + probe["problem"] = _problem_dict(partial) + return probe + + +def _unwrap_plain_text( + response: dict[str, Any], mutation_key: str +) -> KnowledgeBasePlainTextPayload: + payload = response.get(mutation_key) + if isinstance(payload, dict): + plain_text = payload.get("knowledgeBasePlainText") + if isinstance(plain_text, dict): + return plain_text + return {} diff --git a/packages/sdk/src/pipefy_sdk/services/types.py b/packages/sdk/src/pipefy_sdk/services/types.py index 786fc00e..e8f79491 100644 --- a/packages/sdk/src/pipefy_sdk/services/types.py +++ b/packages/sdk/src/pipefy_sdk/services/types.py @@ -126,3 +126,57 @@ class ProviderAccessProbeResult(TypedDict, total=False): provider_count: int note: str problem: dict[str, Any] + + +class KnowledgeBasePayload(TypedDict, total=False): + """One item from the pipe's knowledge base list (``aiKnowledgeBases``). + + ``type`` is the JSON:API resource type carried through from the backend + (e.g. ``knowledge_base_plain_texts``, ``knowledge_base_documents``, + ``data_lookups``); it can be null. ``id`` is the data-source UUID used to + attach the source to an agent/behavior via ``dataSourceIds``. + """ + + id: str + type: str | None + name: str + description: str | None + updatedAt: str | None + + +class KnowledgeBasePlainTextPayload(TypedDict, total=False): + """A single pipe-scoped knowledge base plain text. + + Returned by the get query and by create/update. ``content`` and ``name`` are + always present (non-null in the schema); ``description`` may be null only on + legacy rows — new writes always carry one (see the write validators). + """ + + id: str + name: str + description: str | None + content: str + updatedAt: str | None + + +class KnowledgeBaseDeleteResult(TypedDict): + """Outcome of ``deleteAiKnowledgeBasePlainText``.""" + + success: bool + errors: list[str] + + +class KnowledgeBaseAccessProbeResult(TypedDict, total=False): + """Outcome of the knowledge-base read-access probe (pipe-scoped). + + ``ok`` is True when the list query succeeded (proves *read* access only — + ``read_ai_agents`` on the pipe — never the ``manage_ai_agents`` entitlement + that plain-text create/update/delete require). ``knowledge_base_count`` is + the number of items visible on the pipe. On failure, ``problem`` carries the + classified GraphQL problem (kind/message/code/correlation_id). + """ + + ok: bool + knowledge_base_count: int + note: str + problem: dict[str, Any] diff --git a/packages/sdk/tests/services/test_knowledge_base_service.py b/packages/sdk/tests/services/test_knowledge_base_service.py new file mode 100644 index 00000000..cbcff69e --- /dev/null +++ b/packages/sdk/tests/services/test_knowledge_base_service.py @@ -0,0 +1,359 @@ +"""Unit tests for KnowledgeBaseService (list, plain text CRUD, access probe).""" + +from __future__ import annotations + +import pytest +from _shared.mock_clients import mock_executor + +from pipefy_sdk.graphql_executor import GraphQLResult +from pipefy_sdk.services.knowledge_base_service import ( + MAX_PLAIN_TEXT_CONTENT_LENGTH, + MAX_PLAIN_TEXT_DESCRIPTION_LENGTH, + KnowledgeBaseService, +) + +PLAIN_TEXT_NODE = { + "id": "kb-1", + "type": "knowledge_base_plain_texts", + "name": "Onboarding", + "description": "How to onboard", + "updatedAt": "2026-07-17T00:00:00Z", +} + +DOCUMENT_NODE = { + "id": "kb-2", + "type": "knowledge_base_documents", + "name": "Handbook", + "description": "PDF", + "updatedAt": "2026-07-16T00:00:00Z", +} + +PLAIN_TEXT_FULL = { + "id": "kb-1", + "name": "Onboarding", + "description": "How to onboard", + "content": "Step 1...", + "updatedAt": "2026-07-17T00:00:00Z", +} + + +class TestGetAiKnowledgeBases: + @pytest.mark.anyio + async def test_returns_items(self): + executor = mock_executor({"aiKnowledgeBases": [PLAIN_TEXT_NODE, DOCUMENT_NODE]}) + service = KnowledgeBaseService(executor=executor) + + items = await service.get_ai_knowledge_bases("pipe-uuid-1") + + assert items == [PLAIN_TEXT_NODE, DOCUMENT_NODE] + _, variables = executor.execute_query.await_args.args + assert variables == {"pipeUuid": "pipe-uuid-1"} + + @pytest.mark.anyio + async def test_null_list_yields_empty(self): + executor = mock_executor({"aiKnowledgeBases": None}) + service = KnowledgeBaseService(executor=executor) + + assert await service.get_ai_knowledge_bases("pipe-uuid-1") == [] + + @pytest.mark.anyio + async def test_blank_pipe_uuid_rejected_before_wire(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="pipe_uuid"): + await service.get_ai_knowledge_bases(" ") + executor.execute_query.assert_not_awaited() + + +class TestGetAiKnowledgeBasePlainText: + @pytest.mark.anyio + async def test_returns_plain_text(self): + executor = mock_executor({"aiKnowledgeBasePlainText": PLAIN_TEXT_FULL}) + service = KnowledgeBaseService(executor=executor) + + result = await service.get_ai_knowledge_base_plain_text("kb-1", "pipe-uuid-1") + + assert result == PLAIN_TEXT_FULL + _, variables = executor.execute_query.await_args.args + assert variables == {"id": "kb-1", "pipeUuid": "pipe-uuid-1"} + + @pytest.mark.anyio + async def test_null_yields_empty_dict(self): + executor = mock_executor({"aiKnowledgeBasePlainText": None}) + service = KnowledgeBaseService(executor=executor) + + assert await service.get_ai_knowledge_base_plain_text("kb-x", "p") == {} + + +class TestCreatePlainText: + @pytest.mark.anyio + async def test_wraps_input_and_returns_payload(self): + executor = mock_executor( + { + "createAiKnowledgeBasePlainText": { + "knowledgeBasePlainText": PLAIN_TEXT_FULL + } + } + ) + service = KnowledgeBaseService(executor=executor) + + result = await service.create_ai_knowledge_base_plain_text( + "pipe-uuid-1", + name="Onboarding", + content="Step 1...", + description="How to onboard", + ) + + assert result == PLAIN_TEXT_FULL + _, variables = executor.execute_query.await_args.args + assert variables == { + "input": { + "pipeUuid": "pipe-uuid-1", + "name": "Onboarding", + "content": "Step 1...", + "description": "How to onboard", + } + } + + @pytest.mark.anyio + @pytest.mark.parametrize("field", ["name", "content", "description"]) + async def test_blank_required_field_rejected_before_wire(self, field): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + kwargs = { + "name": "n", + "content": "c", + "description": "d", + field: " ", + } + + with pytest.raises(ValueError, match=field): + await service.create_ai_knowledge_base_plain_text("p", **kwargs) + executor.execute_query.assert_not_awaited() + + @pytest.mark.anyio + async def test_content_over_limit_rejected(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="3500"): + await service.create_ai_knowledge_base_plain_text( + "p", + name="n", + content="x" * (MAX_PLAIN_TEXT_CONTENT_LENGTH + 1), + description="d", + ) + executor.execute_query.assert_not_awaited() + + @pytest.mark.anyio + async def test_description_over_limit_rejected(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="900"): + await service.create_ai_knowledge_base_plain_text( + "p", + name="n", + content="c", + description="y" * (MAX_PLAIN_TEXT_DESCRIPTION_LENGTH + 1), + ) + executor.execute_query.assert_not_awaited() + + @pytest.mark.anyio + async def test_content_at_limit_accepted(self): + executor = mock_executor( + { + "createAiKnowledgeBasePlainText": { + "knowledgeBasePlainText": PLAIN_TEXT_FULL + } + } + ) + service = KnowledgeBaseService(executor=executor) + + await service.create_ai_knowledge_base_plain_text( + "p", + name="n", + content="x" * MAX_PLAIN_TEXT_CONTENT_LENGTH, + description="d", + ) + executor.execute_query.assert_awaited_once() + + +class TestUpdatePlainText: + @pytest.mark.anyio + async def test_only_sends_provided_fields(self): + executor = mock_executor( + { + "updateAiKnowledgeBasePlainText": { + "knowledgeBasePlainText": PLAIN_TEXT_FULL + } + } + ) + service = KnowledgeBaseService(executor=executor) + + await service.update_ai_knowledge_base_plain_text( + "kb-1", "pipe-uuid-1", content="New content" + ) + + _, variables = executor.execute_query.await_args.args + assert variables == { + "input": { + "pipeUuid": "pipe-uuid-1", + "plainTextId": "kb-1", + "content": "New content", + } + } + + @pytest.mark.anyio + async def test_no_fields_rejected_before_wire(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="at least one"): + await service.update_ai_knowledge_base_plain_text("kb-1", "pipe-uuid-1") + executor.execute_query.assert_not_awaited() + + @pytest.mark.anyio + async def test_provided_field_validated(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="3500"): + await service.update_ai_knowledge_base_plain_text( + "kb-1", "p", content="x" * (MAX_PLAIN_TEXT_CONTENT_LENGTH + 1) + ) + executor.execute_query.assert_not_awaited() + + +class TestDeletePlainText: + @pytest.mark.anyio + async def test_success(self): + executor = mock_executor( + {"deleteAiKnowledgeBasePlainText": {"success": True, "errors": []}} + ) + service = KnowledgeBaseService(executor=executor) + + result = await service.delete_ai_knowledge_base_plain_text("kb-1", "p") + + assert result == {"success": True, "errors": []} + _, variables = executor.execute_query.await_args.args + assert variables == {"input": {"pipeUuid": "p", "plainTextId": "kb-1"}} + + @pytest.mark.anyio + async def test_failure_surfaces_errors(self): + executor = mock_executor( + {"deleteAiKnowledgeBasePlainText": {"success": False, "errors": ["nope"]}} + ) + service = KnowledgeBaseService(executor=executor) + + result = await service.delete_ai_knowledge_base_plain_text("kb-1", "p") + + assert result == {"success": False, "errors": ["nope"]} + + +class TestValidateKnowledgeBaseAccess: + @pytest.mark.anyio + async def test_green_probe_reports_count_and_read_only_note(self): + executor = mock_executor( + execute_result=GraphQLResult( + data={"aiKnowledgeBases": [PLAIN_TEXT_NODE, DOCUMENT_NODE]}, errors=[] + ) + ) + service = KnowledgeBaseService(executor=executor) + + probe = await service.validate_knowledge_base_access("pipe-uuid-1") + + assert probe["ok"] is True + assert probe["knowledge_base_count"] == 2 + assert "read access only" in probe["note"].lower() + assert "manage_ai_agents" in probe["note"] + + @pytest.mark.anyio + async def test_empty_list_is_still_ok(self): + executor = mock_executor( + execute_result=GraphQLResult(data={"aiKnowledgeBases": []}, errors=[]) + ) + service = KnowledgeBaseService(executor=executor) + + probe = await service.validate_knowledge_base_access("pipe-uuid-1") + + assert probe["ok"] is True + assert probe["knowledge_base_count"] == 0 + + @pytest.mark.anyio + async def test_permission_denied_maps_to_structured_problem(self): + executor = mock_executor( + execute_result=GraphQLResult( + data={"aiKnowledgeBases": None}, + errors=[ + { + "message": "Permission denied", + "extensions": { + "code": "PERMISSION_DENIED", + "correlation_id": "corr-9", + }, + } + ], + ) + ) + service = KnowledgeBaseService(executor=executor) + + probe = await service.validate_knowledge_base_access("pipe-uuid-1") + + assert probe["ok"] is False + assert probe["problem"]["kind"] == "permission_denied" + assert probe["problem"]["correlation_id"] == "corr-9" + + @pytest.mark.anyio + async def test_not_found_maps_to_structured_problem(self): + executor = mock_executor( + execute_result=GraphQLResult( + data={"aiKnowledgeBases": None}, + errors=[ + { + "message": "Couldn't find Pipe with uuid bogus", + "extensions": {"code": "RESOURCE_NOT_FOUND"}, + } + ], + ) + ) + service = KnowledgeBaseService(executor=executor) + + probe = await service.validate_knowledge_base_access("bogus") + + assert probe["ok"] is False + assert probe["problem"]["kind"] == "not_found" + + @pytest.mark.anyio + async def test_partial_errors_alongside_data_stay_visible(self): + executor = mock_executor( + execute_result=GraphQLResult( + data={"aiKnowledgeBases": [PLAIN_TEXT_NODE]}, + errors=[ + { + "message": "Permission denied", + "extensions": {"code": "PERMISSION_DENIED"}, + } + ], + ) + ) + service = KnowledgeBaseService(executor=executor) + + probe = await service.validate_knowledge_base_access("pipe-uuid-1") + + assert probe["ok"] is True + assert probe["problem"]["kind"] == "permission_denied" + assert "also carried GraphQL errors" in probe["note"] + + @pytest.mark.anyio + async def test_null_data_without_errors_reports_runtime_problem(self): + executor = mock_executor( + execute_result=GraphQLResult(data={"aiKnowledgeBases": None}, errors=[]) + ) + service = KnowledgeBaseService(executor=executor) + + probe = await service.validate_knowledge_base_access("pipe-uuid-1") + + assert probe["ok"] is False + assert probe["problem"]["kind"] == "runtime" diff --git a/packages/sdk/tests/test_ai_preflight_data_sources.py b/packages/sdk/tests/test_ai_preflight_data_sources.py new file mode 100644 index 00000000..6a9237fc --- /dev/null +++ b/packages/sdk/tests/test_ai_preflight_data_sources.py @@ -0,0 +1,187 @@ +"""Tests for the data_source_ids membership check in validate_ai_agent_behaviors_sdk.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from _shared.ai_agent_test_payloads import minimal_behavior_dict +from _shared.fixture_ids import EXAMPLE_PIPE_ID + +from pipefy_sdk.ai_preflight import ( + _collect_configured_data_source_ids, + _data_source_membership_warnings, + validate_ai_agent_behaviors_sdk, +) + +PIPE_UUID = "pipe-uuid-1" + + +CLEAN_FIELD_ID = "900000001" + + +def _behavior_with_data_sources(ids: list[str], *, snake: bool = False) -> dict: + behavior = minimal_behavior_dict() + key = "data_source_ids" if snake else "dataSourceIds" + behavior["actionParams"]["aiBehaviorParams"][key] = ids + return behavior + + +def _clean_behavior_with_data_sources(ids: list[str]) -> dict: + """Behavior whose action targets the source pipe and a field present in context.""" + behavior = minimal_behavior_dict(pipe_id=EXAMPLE_PIPE_ID, field_id=CLEAN_FIELD_ID) + behavior["actionParams"]["aiBehaviorParams"]["dataSourceIds"] = ids + return behavior + + +class TestCollectConfiguredDataSourceIds: + def test_unions_agent_and_behavior_level(self): + behaviors = [ + _behavior_with_data_sources(["kb-b1"]), + _behavior_with_data_sources(["kb-b2"], snake=True), + ] + result = _collect_configured_data_source_ids(behaviors, ["kb-a1", "kb-b1"]) + assert result == {"kb-a1", "kb-b1", "kb-b2"} + + def test_ignores_blank_ids_and_strips_padding(self): + behaviors = [_behavior_with_data_sources([" ", " kb-x "])] + result = _collect_configured_data_source_ids(behaviors, ["", " ", " kb-a "]) + assert result == {"kb-x", "kb-a"} + + def test_empty_when_none_configured(self): + result = _collect_configured_data_source_ids([minimal_behavior_dict()], None) + assert result == set() + + +class TestDataSourceMembershipWarnings: + @pytest.mark.asyncio + async def test_missing_id_warns(self): + client = AsyncMock() + client.get_pipe = AsyncMock(return_value={"pipe": {"uuid": PIPE_UUID}}) + client.get_ai_knowledge_bases = AsyncMock(return_value=[{"id": "kb-known"}]) + + warnings = await _data_source_membership_warnings( + client, EXAMPLE_PIPE_ID, {"kb-known", "kb-missing"} + ) + + assert len(warnings) == 1 + assert "kb-missing" in warnings[0] + client.get_ai_knowledge_bases.assert_awaited_once_with(PIPE_UUID) + + @pytest.mark.asyncio + async def test_all_known_no_warning(self): + client = AsyncMock() + client.get_pipe = AsyncMock(return_value={"pipe": {"uuid": PIPE_UUID}}) + client.get_ai_knowledge_bases = AsyncMock( + return_value=[{"id": "kb-1"}, {"id": "kb-2"}] + ) + + warnings = await _data_source_membership_warnings( + client, EXAMPLE_PIPE_ID, {"kb-1", "kb-2"} + ) + + assert warnings == [] + + @pytest.mark.asyncio + async def test_kb_list_failure_yields_single_warning(self): + client = AsyncMock() + client.get_pipe = AsyncMock(return_value={"pipe": {"uuid": PIPE_UUID}}) + client.get_ai_knowledge_bases = AsyncMock( + side_effect=RuntimeError("permission denied") + ) + + warnings = await _data_source_membership_warnings( + client, EXAMPLE_PIPE_ID, {"kb-1", "kb-2", "kb-3"} + ) + + assert len(warnings) == 1 + assert "probe failed" in warnings[0] + + @pytest.mark.asyncio + async def test_pipe_fetch_failure_yields_single_warning(self): + client = AsyncMock() + client.get_pipe = AsyncMock(side_effect=RuntimeError("boom")) + + warnings = await _data_source_membership_warnings( + client, EXAMPLE_PIPE_ID, {"kb-1"} + ) + + assert len(warnings) == 1 + assert "failed to load" in warnings[0] + + @pytest.mark.asyncio + async def test_missing_uuid_yields_single_warning(self): + client = AsyncMock() + client.get_pipe = AsyncMock(return_value={"pipe": {}}) + + warnings = await _data_source_membership_warnings( + client, EXAMPLE_PIPE_ID, {"kb-1"} + ) + + assert len(warnings) == 1 + assert "no uuid" in warnings[0] + + +def _context_client(*, knowledge_bases) -> AsyncMock: + """A client stubbed for the full validate flow with one known field in context.""" + client = AsyncMock() + client.get_pipe = AsyncMock( + return_value={ + "pipe": { + "uuid": PIPE_UUID, + "phases": [], + "start_form_fields": [ + {"id": "clean_slug", "internal_id": CLEAN_FIELD_ID} + ], + } + } + ) + client.get_pipe_relations = AsyncMock(return_value={"children": [], "parents": []}) + client.get_phase_fields = AsyncMock(return_value={"fields": []}) + client.get_ai_knowledge_bases = AsyncMock(return_value=knowledge_bases) + return client + + +class TestValidateFlowWithDataSources: + @pytest.mark.asyncio + async def test_unknown_id_is_warning_not_problem(self): + client = _context_client(knowledge_bases=[{"id": "kb-known"}]) + behavior = _clean_behavior_with_data_sources(["kb-known"]) + + result = await validate_ai_agent_behaviors_sdk( + client, + EXAMPLE_PIPE_ID, + [behavior], + data_source_ids=["kb-agent-missing"], + ) + + assert result["valid"] is True + assert any("kb-agent-missing" in w for w in result["warnings"]) + assert not any("kb-known" in w for w in result["warnings"]) + + @pytest.mark.asyncio + async def test_no_data_sources_skips_kb_probe(self): + client = _context_client(knowledge_bases=[{"id": "kb-known"}]) + + result = await validate_ai_agent_behaviors_sdk( + client, + EXAMPLE_PIPE_ID, + [minimal_behavior_dict(pipe_id=EXAMPLE_PIPE_ID, field_id=CLEAN_FIELD_ID)], + ) + + assert result["valid"] is True + client.get_ai_knowledge_bases.assert_not_awaited() + + @pytest.mark.asyncio + async def test_failed_probe_single_warning_valid_stays_true(self): + client = _context_client(knowledge_bases=[]) + client.get_ai_knowledge_bases = AsyncMock(side_effect=RuntimeError("denied")) + behavior = _clean_behavior_with_data_sources(["kb-b"]) + + result = await validate_ai_agent_behaviors_sdk( + client, EXAMPLE_PIPE_ID, [behavior], data_source_ids=["kb-a"] + ) + + assert result["valid"] is True + probe_warnings = [w for w in result["warnings"] if "probe failed" in w] + assert len(probe_warnings) == 1 diff --git a/skills/ai-agents/pipefy-ai-agents/SKILL.md b/skills/ai-agents/pipefy-ai-agents/SKILL.md index c565d53e..ed15a949 100644 --- a/skills/ai-agents/pipefy-ai-agents/SKILL.md +++ b/skills/ai-agents/pipefy-ai-agents/SKILL.md @@ -3,7 +3,8 @@ name: pipefy-ai-agents description: > Use this skill when the user wants to create, read, update, delete, or troubleshoot AI agents (conversational agents with behaviors). - Covers 7 MCP tools including pre-flight validation. + Covers 7 MCP tools including pre-flight validation, plus pipe-scoped + knowledge bases (list, plain text CRUD, access probe) attached via dataSourceIds. For traditional automations and AI automations, see skills/automations/. tags: [pipefy, ai-agents, behaviors, conversational] --- @@ -136,6 +137,7 @@ Inside `actionParams.aiBehaviorParams` a behavior may also carry: `capabilityType` is not checked against a fixed set — any value passes through and the API validates the enum on write, so new capabilities work without a toolkit update. Validation checks **shape only, not entitlement** — a capability may still require organization-level enablement to have any effect, so a green pre-flight does not guarantee the capability is active for the org. - **`providerId`** / **`systemProviderId`** — pick the behavior's LLM provider. Set **at most one** (a behavior resolves to a single active provider). Discover valid IDs with `get_llm_providers` (CLI: `pipefy ai-provider list`): each provider carries `type` — use `providerId` for a custom (`byom`) provider and `systemProviderId` for a Pipefy-managed (`system`) one. `get_default_llm_provider` shows what a behavior falls back to when neither is set. IDs are also visible in the organization's AI settings in the Pipefy UI. +- **`dataSourceIds`** — knowledge base sources the behavior can draw on. Each ID is a knowledge base item ID from `get_ai_knowledge_bases` (CLI: `pipefy kb list`). Agents also carry an agent-level `data_source_ids`; the two are unioned. See [Knowledge bases](#knowledge-bases-data-sources) below for the create → attach flow. ```json { @@ -154,6 +156,7 @@ Inside `actionParams.aiBehaviorParams` a behavior may also carry: - Action types are valid - Behavior structure passes Pydantic validation (including canonical `capabilitiesAttributes` shape and at most one of `providerId` / `systemProviderId`) - Field IDs are checked against start-form and phase fields, accepting both slug `id` and numeric `internal_id`. Placeholders like `%{field:}` or `%{field:}` are validated but **not rewritten** at this step. +- Pass `data_source_ids` (agent-level) to also check knowledge base membership: it is unioned with each behavior's `dataSourceIds` and checked against the pipe's knowledge bases. Unknown IDs are **warnings only** (`valid` stays true); if the knowledge base list cannot be read, a single warning is added and the check is skipped. ### 7 — Create the agent @@ -173,6 +176,27 @@ On create/update, slug `fieldId` values are resolved to numeric `internal_id`, ` --- +## Knowledge bases (data sources) + +Knowledge bases are pipe-scoped data sources an agent draws on. Attach one by putting its ID in a behavior's `dataSourceIds` (or the agent-level `data_source_ids`). All knowledge base operations are scoped by the pipe **UUID** (`pipe_uuid`), not the numeric pipe ID — `get_pipe` returns the `uuid`. + +| Tool (MCP) | CLI | Read-only | Purpose | +|------------|-----|-----------|---------| +| `get_ai_knowledge_bases` | `pipefy kb list` | Yes | List every item on a pipe (plain texts, documents, data lookups); each has an `id` for `dataSourceIds`. | +| `get_ai_knowledge_base_plain_text` | `pipefy kb plain-text get` | Yes | Fetch one plain text with its content. | +| `create_ai_knowledge_base_plain_text` | `pipefy kb plain-text create` | No | Create a plain text (`name`, `content` 1-3500, `description` 1-900 — all required). | +| `update_ai_knowledge_base_plain_text` | `pipefy kb plain-text update` | No | Partial update; pass at least one of name/content/description. | +| `delete_ai_knowledge_base_plain_text` | `pipefy kb plain-text delete` | No | **(Two-step destructive)** MCP needs `confirm=true`; CLI needs `--yes`. | +| `validate_knowledge_base_access` | `pipefy kb validate-access` | Yes | Probe read access before writes. | + +### Flow: validate-access → create plain text → attach + +1. **Probe access** — `validate_knowledge_base_access(pipe_uuid)` (CLI: `pipefy kb validate-access`). A green result proves read access only (`read_ai_agents`), never the `manage_ai_agents` entitlement writes need. The CLI create/update commands gate on this automatically; MCP callers should probe first (create/update do not auto-probe). +2. **Create the source** — `create_ai_knowledge_base_plain_text(pipe_uuid, name, content, description)`. Limits fail fast client-side: `content` 1-3500 chars, `description` 1-900 chars (both required). Keep the returned `id`. +3. **Attach** — add that `id` to a behavior's `dataSourceIds` (or the agent-level `data_source_ids`) when calling `create_ai_agent` / `update_ai_agent`. Validate first with `validate_ai_agent_behaviors(pipe_id, behaviors, data_source_ids=[...])` — unknown IDs surface as warnings. + +--- + ## Token normalization & slug resolution Instructions accept five token aliases — all normalize to canonical `%{field:}`: From 98c3a8d6170f23d0106a51c4d9a213e487af46a3 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:14:39 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20CLI?= =?UTF-8?q?=20empty-get=20parity,=20scoped=20discovery=20hint,=20empty-wri?= =?UTF-8?q?te=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI `kb plain-text get` now mirrors the MCP tool's not-found handling: an empty SDK result returns success=false and exits 1 instead of reporting success. The MCP not-found discovery hint is scoped to per-id tools; the list tool's own failure no longer tells the caller to retry the call that just failed. A write mutation returning no errors but a null nested payload now raises in the SDK instead of unwrapping to an empty success, so create/update can never report success for a write that may not have persisted. --- .../src/pipefy_cli/commands/knowledge_base.py | 16 +++++++++-- .../cli/tests/fixtures/cli_help_golden.txt | 3 ++ .../cli/tests/test_knowledge_base_commands.py | 28 +++++++++++++++++++ .../pipefy_mcp/tools/knowledge_base_tools.py | 12 +++++--- .../tests/tools/test_knowledge_base_tools.py | 17 +++++++++++ .../services/knowledge_base_service.py | 12 ++++++-- .../services/test_knowledge_base_service.py | 22 +++++++++++++++ 7 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/pipefy_cli/commands/knowledge_base.py b/packages/cli/src/pipefy_cli/commands/knowledge_base.py index 60164ff4..657aeda2 100644 --- a/packages/cli/src/pipefy_cli/commands/knowledge_base.py +++ b/packages/cli/src/pipefy_cli/commands/knowledge_base.py @@ -74,15 +74,27 @@ def kb_plain_text_get( False, "--json", "-j", help="Print machine-readable JSON to stdout." ), ) -> None: - """Fetch one knowledge base plain text with its content (``get_ai_knowledge_base_plain_text``).""" + """Fetch one knowledge base plain text with its content (``get_ai_knowledge_base_plain_text``). + + Exits 1 with ``success: false`` when the API resolves no plain text for the + id (mirrors the MCP tool's not-found handling). + """ async def factory(client: PipefyClient): plain_text = await client.get_ai_knowledge_base_plain_text( plain_text_id, pipe_uuid ) + if not plain_text: + return { + "success": False, + "error": ( + f"Knowledge base plain text not found: {plain_text_id}. " + "Use `pipefy kb list` to list knowledge base IDs for the pipe." + ), + } return {"success": True, "knowledge_base_plain_text": plain_text} - run_cli_command(ctx, json_out, factory) + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) @plain_text_app.command("create") diff --git a/packages/cli/tests/fixtures/cli_help_golden.txt b/packages/cli/tests/fixtures/cli_help_golden.txt index b7fe13c5..b9cb4f4d 100644 --- a/packages/cli/tests/fixtures/cli_help_golden.txt +++ b/packages/cli/tests/fixtures/cli_help_golden.txt @@ -1746,6 +1746,9 @@ Usage: pipefy kb plain-text get [OPTIONS] Fetch one knowledge base plain text with its content (``get_ai_knowledge_base_plain_text``). + Exits 1 with ``success: false`` when the API resolves no plain text for the id + (mirrors the MCP tool's not-found handling). + ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ * --id TEXT Knowledge base plain text ID (from `pipefy kb │ │ list`). │ diff --git a/packages/cli/tests/test_knowledge_base_commands.py b/packages/cli/tests/test_knowledge_base_commands.py index 9122e10f..374718cf 100644 --- a/packages/cli/tests/test_knowledge_base_commands.py +++ b/packages/cli/tests/test_knowledge_base_commands.py @@ -120,6 +120,34 @@ def test_kb_plain_text_get_json(runner, clean_pipefy_env, saved_cwd, monkeypatch ) +def test_kb_plain_text_get_empty_result_exits_1( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + """Missing id resolves to `{}` from the SDK; the CLI must not report success.""" + _env(monkeypatch) + mock_client = MagicMock() + mock_client.get_ai_knowledge_base_plain_text = AsyncMock(return_value={}) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "plain-text", + "get", + "--id", + "kb-missing", + "--pipe-uuid", + "pipe-uuid-1", + "--json", + ], + ) + assert result.exit_code == 1 + data = json.loads(result.stdout) + assert data["success"] is False + assert "not found" in data["error"].lower() + + def test_kb_plain_text_create_gated_on_probe( runner, clean_pipefy_env, saved_cwd, monkeypatch ): diff --git a/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py b/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py index 5ce6b88e..eed022b6 100644 --- a/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py @@ -27,18 +27,22 @@ ) -def _kb_tool_error_from_exception(exc: BaseException) -> dict[str, Any]: +def _kb_tool_error_from_exception( + exc: BaseException, *, not_found_hint: bool = True +) -> dict[str, Any]: """Map an SDK/GraphQL exception onto the canonical tool failure envelope. Uses the shared SDK classifier so the kind/code the CLI and probe see is the same reported here. A transport-level failure with no GraphQL errors falls - back to ``str(exc)``. + back to ``str(exc)``. ``not_found_hint`` scopes the id-discovery hint to the + per-id tools; the list tool passes False so its own failure never tells the + caller to retry the call that just failed. """ problem = classify_exception(exc) if problem is None: return tool_error(str(exc)) message = problem.message - if problem.kind.value == "not_found": + if not_found_hint and problem.kind.value == "not_found": message = f"{message} {_KB_ID_DISCOVERY_HINT}" details: dict[str, Any] = {"kind": problem.kind.value} if problem.correlation_id: @@ -87,7 +91,7 @@ async def get_ai_knowledge_bases( try: items = await client.get_ai_knowledge_bases(pipe_uuid.strip()) except Exception as exc: # noqa: BLE001 - return _kb_tool_error_from_exception(exc) + return _kb_tool_error_from_exception(exc, not_found_hint=False) return _kb_success( {"knowledge_bases": items}, message="Knowledge bases retrieved." ) diff --git a/packages/mcp/tests/tools/test_knowledge_base_tools.py b/packages/mcp/tests/tools/test_knowledge_base_tools.py index ba585e00..ba1d7e1f 100644 --- a/packages/mcp/tests/tools/test_knowledge_base_tools.py +++ b/packages/mcp/tests/tools/test_knowledge_base_tools.py @@ -324,3 +324,20 @@ async def test_get_plain_text_not_found_error_adds_discovery_hint( assert payload["success"] is False assert payload["error"]["details"]["kind"] == "not_found" assert "get_ai_knowledge_bases" in tool_error_message(payload) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_knowledge_bases_not_found_has_no_self_referential_hint( + kb_session, mock_kb_client, extract_payload +): + """A failed list must not tell the caller to retry the list tool itself.""" + mock_kb_client.get_ai_knowledge_bases = AsyncMock(side_effect=not_found_error()) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_bases", {"pipe_uuid": "bogus"} + ) + payload = extract_payload(result) + assert payload["success"] is False + assert payload["error"]["details"]["kind"] == "not_found" + assert "get_ai_knowledge_bases" not in tool_error_message(payload) diff --git a/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py b/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py index 517d5b3c..db22428b 100644 --- a/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py +++ b/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py @@ -270,9 +270,17 @@ async def validate_knowledge_base_access( def _unwrap_plain_text( response: dict[str, Any], mutation_key: str ) -> KnowledgeBasePlainTextPayload: + """Unwrap a write mutation's plain text; a missing payload is a failure. + + A write that returns no GraphQL errors but a null ``knowledgeBasePlainText`` + must not read as success — the caller cannot know whether it persisted. + """ payload = response.get(mutation_key) if isinstance(payload, dict): plain_text = payload.get("knowledgeBasePlainText") - if isinstance(plain_text, dict): + if isinstance(plain_text, dict) and plain_text: return plain_text - return {} + raise ValueError( + f"{mutation_key} returned no plain text payload; " + "the write may not have persisted." + ) diff --git a/packages/sdk/tests/services/test_knowledge_base_service.py b/packages/sdk/tests/services/test_knowledge_base_service.py index cbcff69e..a3df6d53 100644 --- a/packages/sdk/tests/services/test_knowledge_base_service.py +++ b/packages/sdk/tests/services/test_knowledge_base_service.py @@ -179,6 +179,18 @@ async def test_content_at_limit_accepted(self): ) executor.execute_query.assert_awaited_once() + @pytest.mark.anyio + async def test_null_payload_without_errors_is_a_failure(self): + executor = mock_executor( + {"createAiKnowledgeBasePlainText": {"knowledgeBasePlainText": None}} + ) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="no plain text payload"): + await service.create_ai_knowledge_base_plain_text( + "p", name="n", content="c", description="d" + ) + class TestUpdatePlainText: @pytest.mark.anyio @@ -225,6 +237,16 @@ async def test_provided_field_validated(self): ) executor.execute_query.assert_not_awaited() + @pytest.mark.anyio + async def test_null_payload_without_errors_is_a_failure(self): + executor = mock_executor( + {"updateAiKnowledgeBasePlainText": {"knowledgeBasePlainText": None}} + ) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="no plain text payload"): + await service.update_ai_knowledge_base_plain_text("kb-1", "p", content="c") + class TestDeletePlainText: @pytest.mark.anyio