Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/tool_instance_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ def validate_workflow(self, value):
raise ValidationError("A tool cannot be moved to another workflow.")
return value

def validate_tool_id(self, value: str) -> str:
"""Refuse deprecated registry tools, except the one an instance already has."""
if self.instance and value == self.instance.tool_id:
return value
if ToolProcessor.is_registry_tool(value):
raise ValidationError(
f"Tool '{value}' is deprecated and can't be added to a workflow."
)
return value

def validate_workflow_id(self, value):
"""Same guard for the declared alias -- ``workflow_id`` is the FK's
attname, so DRF writes the column through it directly.
Expand Down
159 changes: 159 additions & 0 deletions backend/tool_instance_v2/tests/test_deprecated_registry_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Registry tools (Classifier, Text Extractor) run on the deprecated Docker
runner: new workflows must not be able to pick them, while workflows that
already use one keep seeing it.

Unit tests: collaborators are patched per-test, so no database is touched.
"""

from __future__ import annotations

from pathlib import Path
import uuid
from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from rest_framework.test import APIRequestFactory, force_authenticate

from tool_instance_v2 import serializers as _ser_mod
from tool_instance_v2 import tool_processor as _tp_mod
from tool_instance_v2 import views as _views_mod

ToolProcessor = _tp_mod.ToolProcessor
ToolInstanceSerializer = _ser_mod.ToolInstanceSerializer

PROMPT_STUDIO_TOOL_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
WORKFLOW_ID = "11111111-2222-3333-4444-555555555555"
REPO_TOOL_REGISTRY_CONFIG = (
Path(__file__).resolve().parents[3] / "unstract/tool-registry/tool_registry_config"
)


@pytest.fixture
def repo_tool_registry(monkeypatch: pytest.MonkeyPatch) -> None:
"""Resolve tools from the repo's `public_tools.json`, not a deployed volume."""
monkeypatch.setenv("TOOL_REGISTRY_CONFIG_PATH", str(REPO_TOOL_REGISTRY_CONFIG))


def _tool(function_name: str) -> dict[str, Any]:
return {"name": function_name, "function_name": function_name}


def _function_names(tools: list[dict[str, Any]]) -> list[str]:
return [tool["function_name"] for tool in tools]


@pytest.mark.usefixtures("repo_tool_registry")
class TestIsRegistryTool:
"""Reads the real `public_tools.json`, so a renamed tool id fails here."""

@pytest.mark.parametrize("tool_uid", ["classify", "text_extractor"])
def test_public_registry_tools_are_registry_tools(self, tool_uid: str) -> None:
assert ToolProcessor.is_registry_tool(tool_uid)

def test_prompt_studio_tool_is_not_a_registry_tool(self) -> None:
assert not ToolProcessor.is_registry_tool(PROMPT_STUDIO_TOOL_ID)


class TestGetToolList:
def _get_tool_list(self, tool_ids_in_workflow: set[str]) -> list[str]:
registry = MagicMock()
registry.fetch_tools_descriptions.return_value = [
_tool("classify"),
_tool("text_extractor"),
]
with (
patch.object(_tp_mod, "ToolRegistry", MagicMock(return_value=registry)),
patch.object(_tp_mod, "IS_AGENTIC_REGISTRY_AVAILABLE", False),
patch.object(
_tp_mod.PromptStudioRegistryHelper,
"fetch_json_for_registry",
MagicMock(return_value=[_tool(PROMPT_STUDIO_TOOL_ID)]),
),
patch.object(
ToolProcessor,
"_get_tool_ids_in_workflow",
MagicMock(return_value=tool_ids_in_workflow),
Comment on lines +73 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Existing-tool lookup lacks coverage The retention tests mock the lookup for tool IDs in a workflow, so they check the list filter but not the new database query. A regression in that query could hide a deprecated tool from a workflow that already uses it without failing these tests. Please add a database-backed test for an accessible workflow with an existing registry tool and one without it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/tool_instance_v2/tests/test_deprecated_registry_tools.py
Line: 73-76

Comment:
**Existing-tool lookup lacks coverage** The retention tests mock the lookup for tool IDs in a workflow, so they check the list filter but not the new database query. A regression in that query could hide a deprecated tool from a workflow that already uses it without failing these tests. Please add a database-backed test for an accessible workflow with an existing registry tool and one without it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

),
):
return _function_names(
ToolProcessor.get_tool_list(MagicMock(name="user"), WORKFLOW_ID)
)

def test_registry_tools_are_hidden_from_a_workflow_without_them(self) -> None:
assert self._get_tool_list(set()) == [PROMPT_STUDIO_TOOL_ID]

def test_registry_tool_already_in_the_workflow_stays_listed(self) -> None:
assert self._get_tool_list({"classify"}) == [
"classify",
PROMPT_STUDIO_TOOL_ID,
]

def test_prompt_studio_tool_in_the_workflow_does_not_unhide_registry_tools(
self,
) -> None:
assert self._get_tool_list({PROMPT_STUDIO_TOOL_ID}) == [PROMPT_STUDIO_TOOL_ID]

def test_no_workflow_means_no_tool_ids_and_no_query(self) -> None:
with patch.object(_tp_mod.Workflow, "objects") as workflow_objects:
assert ToolProcessor._get_tool_ids_in_workflow(MagicMock(), None) == set()
workflow_objects.for_user.assert_not_called()


@pytest.mark.usefixtures("repo_tool_registry")
class TestToolIdValidation:
"""`is_valid` is what the viewset's create and update both run."""

def _errors(self, data: dict[str, Any], instance: Any = None) -> dict[str, Any]:
serializer = ToolInstanceSerializer(
instance, data=data, partial=instance is not None
)
serializer.is_valid()
return serializer.errors

@pytest.mark.parametrize("tool_id", ["classify", "text_extractor"])
def test_create_with_a_registry_tool_is_rejected(self, tool_id: str) -> None:
errors = self._errors({"workflow_id": WORKFLOW_ID, "tool_id": tool_id})
assert "deprecated" in str(errors["tool_id"])

def test_create_with_a_prompt_studio_tool_is_accepted(self) -> None:
errors = self._errors(
{"workflow_id": WORKFLOW_ID, "tool_id": PROMPT_STUDIO_TOOL_ID}
)
assert "tool_id" not in errors

def test_switching_an_instance_to_a_registry_tool_is_rejected(self) -> None:
instance = MagicMock(name="instance", tool_id=PROMPT_STUDIO_TOOL_ID)
errors = self._errors({"tool_id": "classify"}, instance=instance)
assert "deprecated" in str(errors["tool_id"])

def test_an_instance_keeps_the_registry_tool_it_already_has(self) -> None:
instance = MagicMock(name="instance", tool_id="classify")
errors = self._errors({"tool_id": "classify"}, instance=instance)
assert "tool_id" not in errors


class TestGetToolListView:
def _get(self, query: str) -> Any:
request = APIRequestFactory().get(f"/tool/{query}")
force_authenticate(request, user=MagicMock(name="user"))
with patch.object(
_views_mod.ToolProcessor, "get_tool_list", MagicMock(return_value=[])
) as get_tool_list:
response = _views_mod.get_tool_list(request)
return response, get_tool_list

def test_workflow_id_is_passed_as_a_uuid(self) -> None:
response, get_tool_list = self._get(f"?workflow_id={WORKFLOW_ID}")
assert response.status_code == 200
assert get_tool_list.call_args.args[1] == uuid.UUID(WORKFLOW_ID)

def test_missing_workflow_id_lists_without_a_workflow(self) -> None:
response, get_tool_list = self._get("")
assert response.status_code == 200
assert get_tool_list.call_args.args[1] is None

def test_malformed_workflow_id_is_a_bad_request(self) -> None:
response, get_tool_list = self._get("?workflow_id=not-a-uuid")
assert response.status_code == 400
get_tool_list.assert_not_called()
31 changes: 29 additions & 2 deletions backend/tool_instance_v2/tool_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
from prompt_studio.prompt_studio_registry_v2.prompt_studio_registry_helper import (
PromptStudioRegistryHelper,
)
from workflow_manager.workflow_v2.models.workflow import Workflow

from tool_instance_v2.constants import ToolKey
from tool_instance_v2.exceptions import ToolDoesNotExist
from tool_instance_v2.models import ToolInstance
from unstract.sdk1.constants import AdapterTypes
from unstract.tool_registry.dto import Spec, Tool
from unstract.tool_registry.tool_registry import ToolRegistry
Expand Down Expand Up @@ -130,13 +133,26 @@ def update_schema_with_adapter_configurations(schema: Spec, user: User) -> None:
)

@staticmethod
def get_tool_list(user: User) -> list[dict[str, Any]]:
def is_registry_tool(tool_uid: str) -> bool:
return ToolRegistry().get_tool_by_uid(tool_uid) is not None

@staticmethod
def get_tool_list(
user: User, workflow_id: uuid.UUID | None = None
) -> list[dict[str, Any]]:
"""Function to get a list of tools."""
tool_registry = ToolRegistry()
prompt_studio_tools: list[dict[str, Any]] = (
PromptStudioRegistryHelper.fetch_json_for_registry(user)
)
tool_list: list[dict[str, Any]] = tool_registry.fetch_tools_descriptions()
# Registry tools run on the deprecated Docker runner, so they are only
# listed for a workflow that already uses one.
tool_ids_in_workflow = ToolProcessor._get_tool_ids_in_workflow(user, workflow_id)
tool_list: list[dict[str, Any]] = [
tool
for tool in tool_registry.fetch_tools_descriptions()
if tool.get(ToolKey.FUNCTION_NAME) in tool_ids_in_workflow
]
tool_list = tool_list + prompt_studio_tools

# Add agentic studio tools if available (cloud-only feature)
Expand All @@ -163,3 +179,14 @@ def get_prompt_studio_tool_count(user: User) -> int:
continue

return valid_tools

@staticmethod
def _get_tool_ids_in_workflow(user: User, workflow_id: uuid.UUID | None) -> set[str]:
if not workflow_id:
return set()
workflows = Workflow.objects.for_user(user).filter(pk=workflow_id)
return set(
ToolInstance.objects.filter(workflow__in=workflows).values_list(
"tool_id", flat=True
)
)
14 changes: 12 additions & 2 deletions backend/tool_instance_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
from backend.constants import RequestKey
from tool_instance_v2.constants import ToolInstanceErrors, ToolKey
from tool_instance_v2.constants import ToolInstanceKey as TIKey
from tool_instance_v2.exceptions import FetchToolListFailed, ToolFunctionIsMandatory
from tool_instance_v2.exceptions import (
BadRequestException,
FetchToolListFailed,
ToolFunctionIsMandatory,
)
from tool_instance_v2.models import ToolInstance
from tool_instance_v2.serializers import (
ToolInstanceReorderSerializer as TIReorderSerializer,
Expand Down Expand Up @@ -54,10 +58,16 @@ def get_tool_list(request: Request) -> Response:
Fetches a list of tools available in the Tool registry
"""
if request.method == "GET":
workflow_id = request.query_params.get(WorkflowKey.WF_ID)
if workflow_id:
try:
workflow_id = uuid.UUID(workflow_id)
except ValueError:
raise BadRequestException("Invalid workflow_id.")
try:
logger.info("Fetching tools from the tool registry...")
return Response(
data=ToolProcessor.get_tool_list(request.user),
data=ToolProcessor.get_tool_list(request.user, workflow_id),
status=status.HTTP_200_OK,
)
except Exception as exc:
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/agency/agency/Agency.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ function Agency() {
const getExportedTools = (signal) => {
const requestOptions = {
method: "GET",
url: getUrl(`tool/`),
url: getUrl(`tool/?workflow_id=${projectId}`),
signal,
};
return axiosPrivate(requestOptions)
Expand Down Expand Up @@ -926,6 +926,11 @@ function Agency() {

// Handle tool selection from sidebar
const handleToolSelection = async (functionName) => {
// Re-adding the current tool deletes and recreates its instance, which the
// backend refuses for deprecated registry tools, so leave it in place.
if (details?.tool_instances?.some((ti) => ti.tool_id === functionName)) {
return;
}
setSelectedTool(functionName);

const tool = exportedTools.find((t) => t.function_name === functionName);
Expand Down Expand Up @@ -994,6 +999,11 @@ function Agency() {
setAlertDetails(
handleException(err, "Failed to update Prompt Studio project"),
);
} finally {
// A removed deprecated tool drops out of the list and can't be re-added.
getExportedTools().catch(() => {
// getExportedTools already alerts on failure.
});
}
}
};
Expand Down
Loading