diff --git a/backend/consts/const.py b/backend/consts/const.py index 574d550c0..a3a897043 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -486,7 +486,7 @@ def _parse_otlp_headers(headers_str: str) -> dict: # APP Version -APP_VERSION = "v2.2.1" +APP_VERSION = "v2.2.0" # Skill Creation Streaming Configuration diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 12edea7d5..e65189f2e 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -235,7 +235,7 @@ def save_conversation_assistant(request: AgentRequest, messages: List[str], user message_list.append(message) conversation_req = MessageRequest(conversation_id=request.conversation_id, message_idx=user_role_count * 2 + 1, - role=MESSAGE_ROLE["ASSISTANT"], message=message_list, minio_files=None) + role=MESSAGE_ROLE["ASSISTANT"], message=message_list, minio_files=request.minio_files) save_message(conversation_req, user_id=user_id, tenant_id=tenant_id) diff --git a/docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql b/docker/sql/v2.2.0_0601_add_preserve_source_file_to_knowledge_record_t.sql similarity index 100% rename from docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql rename to docker/sql/v2.2.0_0601_add_preserve_source_file_to_knowledge_record_t.sql diff --git a/docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql b/docker/sql/v2.2.0_0603_add_greeting_fields_to_ag_tenant_agent_t.sql similarity index 100% rename from docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql rename to docker/sql/v2.2.0_0603_add_greeting_fields_to_ag_tenant_agent_t.sql diff --git a/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql b/docker/sql/v2.2.0_0605_add_ag_agent_repository_t.sql similarity index 100% rename from docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql rename to docker/sql/v2.2.0_0605_add_ag_agent_repository_t.sql diff --git a/docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql b/docker/sql/v2.2.0_0609_add_selected_agent_version_no_to_agent_relation_t.sql similarity index 100% rename from docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql rename to docker/sql/v2.2.0_0609_add_selected_agent_version_no_to_agent_relation_t.sql diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index cd46d2aa3..24ec60616 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -171,7 +171,7 @@ export default function AgentGenerateDetail({}) { constraintPrompt: editedAgent.constraint_prompt || "", fewShotsPrompt: editedAgent.few_shots_prompt || "", provideRunSummary: editedAgent.provide_run_summary || false, - verificationEnabled: editedAgent.verification_config?.enabled ?? false, + verificationEnabled: editedAgent.verification_config?.enabled ?? true, businessDescription: editedAgent.business_description || "", businessLogicModelName:editedAgent.business_logic_model_name, businessLogicModelId: editedAgent.business_logic_model_id, @@ -809,7 +809,7 @@ export default function AgentGenerateDetail({}) { - + - + - + - + { const agentInfo = initialData.agent_info[agentKey] as any; return { + agent_id: agentInfo?.agent_id, name: conflict.renamedName || agentInfo?.name || "", display_name: conflict.renamedDisplayName || agentInfo?.display_name || "", task_description: agentInfo?.business_description || agentInfo?.description || "", diff --git a/k8s/helm/deploy.sh b/k8s/helm/deploy.sh index 07522d22c..7a583307d 100755 --- a/k8s/helm/deploy.sh +++ b/k8s/helm/deploy.sh @@ -611,7 +611,7 @@ apply() { sleep 5 for svc in $backend_services; do echo " Waiting for nexent-$svc..." - if kubectl rollout status "deployment/nexent-$svc" -n "$NAMESPACE" --timeout=300s >/dev/null 2>&1; then + if kubectl wait --for=condition=ready pod -l app=nexent-$svc -n $NAMESPACE --timeout=300s 2>/dev/null; then echo " nexent-$svc is ready." else echo " Error: nexent-$svc did not become ready within timeout." diff --git a/k8s/helm/nexent/charts/nexent-common/files/init.sql b/k8s/helm/nexent/charts/nexent-common/files/init.sql index 399c50917..a2f202b90 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -1896,210 +1896,3 @@ COMMENT ON TABLE nexent.user_cas_session_t IS 'Server-side session records for C COMMENT ON COLUMN nexent.user_cas_session_t.session_id IS 'JWT sid claim for revocation checks'; COMMENT ON COLUMN nexent.user_cas_session_t.cas_user_id IS 'User identifier returned by CAS'; COMMENT ON COLUMN nexent.user_cas_session_t.cas_session_index IS 'CAS SessionIndex or service ticket'; - --- Rename params -> config_values, add config_schemas to ag_skill_info_t --- Add tenant_id column for multi-tenancy support -ALTER TABLE nexent.ag_skill_info_t ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(100); - --- Add config_values and config_schemas to ag_skill_info_t -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'nexent' - AND table_name = 'ag_skill_info_t' - AND column_name = 'params' - ) THEN - ALTER TABLE nexent.ag_skill_info_t RENAME COLUMN params TO config_values; - END IF; -END $$; -ALTER TABLE nexent.ag_skill_info_t ADD COLUMN IF NOT EXISTS config_schemas JSON; - --- Comments for ag_skill_info_t columns -COMMENT ON COLUMN nexent.ag_skill_info_t.tenant_id IS 'Tenant ID for multi-tenancy. NULL for pre-existing skills.'; -COMMENT ON COLUMN nexent.ag_skill_info_t.config_values IS 'Runtime parameter values from config/config.yaml'; -COMMENT ON COLUMN nexent.ag_skill_info_t.config_schemas IS 'Parameter metadata list from config/schema.yaml'; - --- Add config_values and config_schemas to ag_skill_instance_t -ALTER TABLE nexent.ag_skill_instance_t ADD COLUMN IF NOT EXISTS config_values JSON; -ALTER TABLE nexent.ag_skill_instance_t ADD COLUMN IF NOT EXISTS config_schemas JSON; - --- Comments for ag_skill_instance_t columns -COMMENT ON COLUMN nexent.ag_skill_instance_t.config_values IS 'Per-agent runtime parameter values from config/config.yaml'; -COMMENT ON COLUMN nexent.ag_skill_instance_t.config_schemas IS 'Per-agent parameter schema overrides from config/schema.yaml'; - --- Migration: ASSET_OWNER role permissions and invitation type comment --- Date: 2026-05-29 --- Description: Add ASSET_OWNER role permissions, SU asset-owner invite permissions, --- update invitation code_type comment, and ensure ag_skill_info_t.tenant_id exists --- Source: commit 15cece97692db2372a978cbdf21b5d5316e79f30 (init.sql) - -SET search_path TO nexent; - -BEGIN; - -COMMENT ON COLUMN nexent.tenant_invitation_code_t.code_type IS - 'Invitation code type: ADMIN_INVITE, DEV_INVITE, USER_INVITE, ASSET_OWNER_INVITE'; - -INSERT INTO nexent.role_permission_t - (role_permission_id, user_role, permission_category, permission_type, permission_subtype) -VALUES - (188, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'CREATE'), - (189, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'READ'), - (190, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'UPDATE'), - (191, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'DELETE'), - (192, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), - (193, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), - (194, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), - (195, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), - (196, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), - (197, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), - (198, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), - (199, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'CREATE'), - (200, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'READ'), - (201, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'UPDATE'), - (202, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'DELETE'), - (203, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'CREATE'), - (204, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'READ'), - (205, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'UPDATE'), - (206, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'DELETE'), - (207, 'ASSET_OWNER', 'RESOURCE', 'KB', 'CREATE'), - (208, 'ASSET_OWNER', 'RESOURCE', 'KB', 'READ'), - (209, 'ASSET_OWNER', 'RESOURCE', 'KB', 'UPDATE'), - (210, 'ASSET_OWNER', 'RESOURCE', 'KB', 'DELETE'), - (211, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'CREATE'), - (212, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'READ'), - (213, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'UPDATE'), - (214, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'DELETE'), - (215, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'CREATE'), - (216, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'READ'), - (217, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'UPDATE'), - (218, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'DELETE'), - (219, 'ASSET_OWNER', 'RESOURCE', 'USER.ROLE', 'READ'), - (220, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), - (221, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/asset-owner-resources') -ON CONFLICT (role_permission_id) DO NOTHING; - -COMMIT; - --- Migration: Add preserve_source_file to knowledge_record_t table --- Date: 2026-06-01 --- Description: Whether to preserve uploaded source documents after vectorization (default: true) - -ALTER TABLE nexent.knowledge_record_t -ADD COLUMN IF NOT EXISTS preserve_source_file BOOLEAN NOT NULL DEFAULT true; - -COMMENT ON COLUMN nexent.knowledge_record_t.preserve_source_file IS 'Whether to preserve uploaded source documents after vectorization'; - --- Migration: Add ag_agent_repository_t table --- Date: 2026-06-05 --- Description: Agent marketplace repository for frozen shareable agent snapshots. - -SET search_path TO nexent; - -BEGIN; - -CREATE SEQUENCE IF NOT EXISTS nexent.ag_agent_repository_t_agent_repository_id_seq; - -CREATE TABLE IF NOT EXISTS nexent.ag_agent_repository_t ( - agent_repository_id BIGINT NOT NULL DEFAULT nextval('nexent.ag_agent_repository_t_agent_repository_id_seq'), - publisher_tenant_id VARCHAR(100) NOT NULL, - publisher_user_id VARCHAR(100) NOT NULL, - agent_id INTEGER NOT NULL, - source_version_no INTEGER NOT NULL, - name VARCHAR(100) NOT NULL, - display_name VARCHAR(100), - description TEXT, - author VARCHAR(100), - category_id INTEGER, - tags TEXT[], - tool_count INTEGER, - version_label VARCHAR(100), - agent_info_json JSONB NOT NULL, - status VARCHAR(30) DEFAULT 'NOT_SHARED', - create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), - updated_by VARCHAR(100), - delete_flag VARCHAR(1) DEFAULT 'N', - CONSTRAINT ag_agent_repository_t_pkey PRIMARY KEY (agent_repository_id) -); - -ALTER SEQUENCE nexent.ag_agent_repository_t_agent_repository_id_seq - OWNED BY nexent.ag_agent_repository_t.agent_repository_id; - -ALTER TABLE nexent.ag_agent_repository_t OWNER TO root; - -COMMENT ON TABLE nexent.ag_agent_repository_t IS 'Agent marketplace repository for frozen shareable agent snapshots'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_repository_id IS 'Agent repository listing ID, unique primary key'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.publisher_tenant_id IS 'Publisher tenant ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.publisher_user_id IS 'Publisher user ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_id IS 'Root agent ID from ag_tenant_agent_t; upsert key with publisher_tenant_id'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.source_version_no IS 'Published version number frozen at share time'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.name IS 'Root agent programmatic name for display and search'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.display_name IS 'Root agent display name'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.description IS 'Root agent description'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.author IS 'Agent author'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.category_id IS 'Optional marketplace category ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.tags IS 'Marketplace tags'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.tool_count IS 'Total tool count across all agents in the bundle (display only)'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.version_label IS 'Repository entry version label for display (e.g. v1.0)'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_info_json IS 'Frozen ExportAndImportDataFormat snapshot with optional skills'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / REJECTED (审核驳回) / SHARED (已共享)'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.create_time IS 'Creation time'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.update_time IS 'Update time'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.created_by IS 'Creator ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.updated_by IS 'Updater ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.delete_flag IS 'Soft delete flag: Y/N'; - -CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_repository_tenant_agent_active - ON nexent.ag_agent_repository_t (publisher_tenant_id, agent_id) - WHERE delete_flag = 'N'; - -CREATE INDEX IF NOT EXISTS idx_agent_repository_publisher_delete - ON nexent.ag_agent_repository_t (publisher_tenant_id, delete_flag); - -CREATE INDEX IF NOT EXISTS idx_agent_repository_status_delete - ON nexent.ag_agent_repository_t (status, delete_flag); - -CREATE INDEX IF NOT EXISTS idx_agent_repository_name_delete - ON nexent.ag_agent_repository_t (name, delete_flag); - -CREATE INDEX IF NOT EXISTS idx_agent_repository_tags_gin - ON nexent.ag_agent_repository_t USING GIN (tags); - -CREATE OR REPLACE FUNCTION update_ag_agent_repository_update_time() -RETURNS TRIGGER AS $$ -BEGIN - NEW.update_time = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION update_ag_agent_repository_update_time() IS 'Auto-update update_time for ag_agent_repository_t'; - -DROP TRIGGER IF EXISTS update_ag_agent_repository_update_time_trigger ON nexent.ag_agent_repository_t; -CREATE TRIGGER update_ag_agent_repository_update_time_trigger -BEFORE UPDATE ON nexent.ag_agent_repository_t -FOR EACH ROW -EXECUTE FUNCTION update_ag_agent_repository_update_time(); - -COMMENT ON TRIGGER update_ag_agent_repository_update_time_trigger ON nexent.ag_agent_repository_t IS 'Trigger to maintain update_time'; - -COMMIT; - --- Migration: Add selected_agent_version_no to ag_agent_relation_t --- Date: 2026-06-09 --- Description: Pin child agent version on parent-child relations at publish time. - -SET search_path TO nexent; - -BEGIN; - -ALTER TABLE nexent.ag_agent_relation_t - ADD COLUMN IF NOT EXISTS selected_agent_version_no INTEGER; - -COMMENT ON COLUMN nexent.ag_agent_relation_t.selected_agent_version_no IS - 'Pinned version of selected_agent_id. NULL = use child current published version at runtime (legacy/draft).'; - -COMMIT; diff --git a/k8s/helm/nexent/charts/nexent-data-process/values.yaml b/k8s/helm/nexent/charts/nexent-data-process/values.yaml index d6bb70a7f..189292667 100644 --- a/k8s/helm/nexent/charts/nexent-data-process/values.yaml +++ b/k8s/helm/nexent/charts/nexent-data-process/values.yaml @@ -12,7 +12,7 @@ resources: memory: 512Mi cpu: 0.5 limits: - memory: 64Gi + memory: 4Gi cpu: 8 config: diff --git a/scripts/deployment/common.sh b/scripts/deployment/common.sh index 006561553..5855af1a0 100755 --- a/scripts/deployment/common.sh +++ b/scripts/deployment/common.sh @@ -319,6 +319,11 @@ deployment_validate() { deployment_error "Local config schemaVersion $DEPLOYMENT_LOADED_SCHEMA_VERSION is incompatible with $DEPLOYMENT_SCHEMA_VERSION. Re-run with --reconfigure." return 1 fi + if [ -n "$DEPLOYMENT_LOADED_APP_VERSION" ] && [ -n "${APP_VERSION:-}" ] && [ -z "${DEPLOYMENT_APP_VERSION_EXPLICIT:-}" ] && [ "$DEPLOYMENT_LOADED_APP_VERSION" != "$APP_VERSION" ]; then + deployment_error "Local config appVersion $DEPLOYMENT_LOADED_APP_VERSION does not match current appVersion $APP_VERSION. Re-run with --reconfigure or pass --app-version." + return 1 + fi + local old_ifs="$IFS" local component IFS=',' diff --git a/sdk/nexent/container/k8s_client.py b/sdk/nexent/container/k8s_client.py index c2fb72741..c1fa4db53 100644 --- a/sdk/nexent/container/k8s_client.py +++ b/sdk/nexent/container/k8s_client.py @@ -8,7 +8,6 @@ import asyncio import logging import socket -import re import uuid import kubernetes @@ -24,47 +23,6 @@ logger = logging.getLogger("nexent.container.kubernetes") -# Kubernetes naming constraints: lowercase alphanumeric or dash, cannot start/end with dash, -# cannot have consecutive dashes, max 253 characters -K8S_NAME_PATTERN = re.compile(r"[^a-z0-9-]+") -K8S_CONSECUTIVE_DASHES = re.compile(r"-+") - - -def _sanitize_k8s_name(name: str) -> str: - """Convert arbitrary string to valid Kubernetes resource name. - - Rules: - - Convert to lowercase - - Replace invalid characters with dash - - Collapse consecutive dashes - - Remove leading/trailing dashes - - Must start with alphanumeric - - Args: - name: Input string to sanitize - - Returns: - Valid Kubernetes name (lowercase alphanumeric and dashes only) - """ - if not name: - return "unknown" - - # Lowercase and replace invalid chars with dash - sanitized = K8S_NAME_PATTERN.sub("-", name.lower()) - - # Collapse consecutive dashes - sanitized = K8S_CONSECUTIVE_DASHES.sub("-", sanitized) - - # Remove leading/trailing dashes - sanitized = sanitized.strip("-") - - # Ensure it starts with alphanumeric - if sanitized and not sanitized[0].isalnum(): - sanitized = "x" + sanitized - - # Fallback if empty - return sanitized if sanitized else "unknown" - class ContainerError(Exception): """Raised when container operation fails""" @@ -119,9 +77,9 @@ def __init__(self, config: KubernetesContainerConfig): def _generate_pod_name(self, service_name: str, tenant_id: str, user_id: str) -> str: """Generate unique pod name with service, tenant, and user segments.""" - safe_name = _sanitize_k8s_name(service_name) - tenant_part = _sanitize_k8s_name(tenant_id)[:8] - user_part = _sanitize_k8s_name(user_id)[:8] + safe_name = "".join(c if c.isalnum() or c == "-" else "-" for c in service_name) + tenant_part = (tenant_id or "")[:8] + user_part = (user_id or "")[:8] uuid_part = uuid.uuid4().hex[:8] return f"mcp-{safe_name}-{tenant_part}-{user_part}-{uuid_part}" @@ -528,7 +486,7 @@ def list_containers( # Filter by service_name if provided if service_name: - safe_name = _sanitize_k8s_name(service_name) + safe_name = "".join(c if c.isalnum() or c == "-" else "-" for c in service_name) pod_component = labels.get(self.LABEL_COMPONENT, "") if safe_name not in pod_component: continue diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index ed43b6691..a9a31a94b 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -198,16 +198,11 @@ def create_local_tool(self, tool_config: ToolConfig): raise ValueError(f"{class_name} not found in local") else: if class_name == "KnowledgeBaseSearchTool": - # Filter out conflicting parameters from params to avoid conflicts. - # Parameters declared with exclude=True cannot be passed to __init__ - # due to smolagents.tools.Tool wrapper restrictions; they are set as - # attributes on the instance after construction, sourced from metadata. - # `document_paths` is intentionally hidden from the LLM and only - # populated via tool_params from the northbound interface. + # Filter out conflicting parameters from params to avoid conflicts + # These parameters have exclude=True and cannot be passed to __init__ + # due to smolagents.tools.Tool wrapper restrictions filtered_params = {k: v for k, v in params.items() - if k not in ["vdb_core", "embedding_model", "observer", - "rerank_model", "display_name_to_index_map", - "document_paths"]} + if k not in ["vdb_core", "embedding_model", "observer", "rerank_model", "display_name_to_index_map"]} # Create instance with only non-excluded parameters tools_obj = tool_class(**filtered_params) # Set excluded parameters directly as attributes after instantiation @@ -221,13 +216,6 @@ def create_local_tool(self, tool_config: ToolConfig): "rerank_model", None) if tool_config.metadata else None tools_obj.display_name_to_index_map = tool_config.metadata.get( "display_name_to_index_map", {}) if tool_config.metadata else {} - # Internal access control: restrict results to documents whose - # path_or_url is in the allow list. Only the northbound interface - # may populate this; never the LLM. - tools_obj.set_document_paths( - tool_config.metadata.get( - "document_paths") if tool_config.metadata else None - ) elif class_name in ["DifySearchTool", "DataMateSearchTool"]: # These parameters have exclude=True and cannot be passed to __init__ filtered_params = {k: v for k, v in params.items() diff --git a/sdk/nexent/core/tools/knowledge_base_search_tool.py b/sdk/nexent/core/tools/knowledge_base_search_tool.py index c0115a0ab..9149ed05d 100644 --- a/sdk/nexent/core/tools/knowledge_base_search_tool.py +++ b/sdk/nexent/core/tools/knowledge_base_search_tool.py @@ -21,21 +21,6 @@ logger = logging.getLogger("knowledge_base_search_tool") -def _unwrap_field_info(value): - """Resolve a value that may be wrapped in a Pydantic FieldInfo. - - Parameters declared with `Field(...)` and `exclude=True` are not expanded by - smolagents' Tool wrapper, so they arrive at `__init__` as raw FieldInfo - instances instead of their declared defaults. This helper extracts the - concrete value so callers can safely treat the result as plain data. - """ - if isinstance(value, FieldInfo): - if value.default_factory is not None: - return value.default_factory() - return value.default - return value - - class KnowledgeBaseSearchTool(Tool): """Knowledge base search tool""" @@ -144,10 +129,7 @@ def __init__( self.rerank_model = rerank_model self.data_process_service = os.getenv("DATA_PROCESS_SERVICE") self.display_name_to_index_map = display_name_to_index_map - # `document_paths` is declared with `exclude=True` so smolagents passes the - # raw FieldInfo default when no value is supplied. Unwrap it here so the - # internal filter is always a concrete list (or None), never a FieldInfo. - self._internal_document_paths = _unwrap_field_info(document_paths) + self._internal_document_paths = document_paths self.record_ops = 1 self.running_prompt_zh = "知识库检索中..." @@ -162,7 +144,7 @@ def set_document_paths(self, document_paths: Optional[List[str]]) -> None: Args: document_paths: List of allowed document path_or_urls. If None, no filtering is applied. """ - self._internal_document_paths = _unwrap_field_info(document_paths) + self._internal_document_paths = document_paths def _convert_to_index_names(self, names: List[str]) -> List[str]: """Convert display names (knowledge_name) to index names if necessary. @@ -206,7 +188,7 @@ def _filter_by_document_paths(self, results: List[dict]) -> List[dict]: Returns: Filtered list containing only results with allowed document paths """ - allowed_paths = _unwrap_field_info(self._internal_document_paths) + allowed_paths = self._internal_document_paths if not allowed_paths: return results diff --git a/test/sdk/container/test_k8s_client.py b/test/sdk/container/test_k8s_client.py index 84e0bc557..42db8c58c 100644 --- a/test/sdk/container/test_k8s_client.py +++ b/test/sdk/container/test_k8s_client.py @@ -11,7 +11,6 @@ KubernetesContainerClient, ContainerError, ContainerConnectionError, - _sanitize_k8s_name, ) from nexent.container.k8s_config import KubernetesContainerConfig @@ -91,79 +90,6 @@ def mock_pod(): return pod -# --------------------------------------------------------------------------- -# Test _sanitize_k8s_name -# --------------------------------------------------------------------------- - - -class TestSanitizeK8sName: - """Test _sanitize_k8s_name helper function""" - - def test_sanitize_basic_alphanumeric(self): - """Test basic alphanumeric string passes through""" - assert _sanitize_k8s_name("test-service") == "test-service" - assert _sanitize_k8s_name("abc123") == "abc123" - - def test_sanitize_lowercase_conversion(self): - """Test uppercase letters are converted to lowercase""" - assert _sanitize_k8s_name("TestService") == "testservice" - assert _sanitize_k8s_name("UPPERCASE") == "uppercase" - - def test_sanitize_special_characters_replaced(self): - """Test special characters are replaced with dash""" - assert _sanitize_k8s_name("test@service") == "test-service" - assert _sanitize_k8s_name("foo#bar") == "foo-bar" - assert _sanitize_k8s_name("test$123") == "test-123" - - def test_sanitize_consecutive_special_chars(self): - """Test consecutive special characters are collapsed to single dash""" - assert _sanitize_k8s_name("foo@@bar") == "foo-bar" - assert _sanitize_k8s_name("test@#$service") == "test-service" - assert _sanitize_k8s_name("a!!b") == "a-b" - - def test_sanitize_leading_special_chars(self): - """Test leading special characters are removed""" - assert _sanitize_k8s_name("@test") == "test" - assert _sanitize_k8s_name("#foo") == "foo" - assert _sanitize_k8s_name("!test@service") == "test-service" - - def test_sanitize_trailing_special_chars(self): - """Test trailing special characters are removed""" - assert _sanitize_k8s_name("test@") == "test" - assert _sanitize_k8s_name("test-service!") == "test-service" - - def test_sanitize_mixed_case_with_specials(self): - """Test mixed case with special characters""" - assert _sanitize_k8s_name("Foo@Bar!Test") == "foo-bar-test" - - def test_sanitize_empty_string(self): - """Test empty string returns 'unknown'""" - assert _sanitize_k8s_name("") == "unknown" - - def test_sanitize_only_special_chars(self): - """Test string with only special characters returns 'unknown'""" - assert _sanitize_k8s_name("@@@") == "unknown" - assert _sanitize_k8s_name("!@#") == "unknown" - - def test_sanitize_none(self): - """Test None returns 'unknown'""" - assert _sanitize_k8s_name(None) == "unknown" - - def test_sanitize_with_dots(self): - """Test dots are converted to dashes""" - assert _sanitize_k8s_name("foo.bar") == "foo-bar" - assert _sanitize_k8s_name("foo...bar") == "foo-bar" - - def test_sanitize_underscore_replaced(self): - """Test underscores are replaced with dash""" - assert _sanitize_k8s_name("foo_bar") == "foo-bar" - - def test_sanitize_spaces_replaced(self): - """Test spaces are replaced with dash""" - assert _sanitize_k8s_name("foo bar") == "foo-bar" - assert _sanitize_k8s_name("foo bar") == "foo-bar" - - # --------------------------------------------------------------------------- # Test KubernetesContainerClient.__init__ # --------------------------------------------------------------------------- @@ -266,72 +192,6 @@ def test_generate_pod_name_with_special_chars(self, k8s_container_client): assert "@" not in name assert "#" not in name - def test_generate_pod_name_consecutive_special_chars(self, k8s_container_client): - """Test pod name generation with consecutive special characters""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "foo@@bar", "tenant123", "user12345") - assert name == "mcp-foo-bar-tenant12-user1234-a1b2c3d4" - assert "--" not in name - - def test_generate_pod_name_leading_special_chars(self, k8s_container_client): - """Test pod name generation with leading special characters""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "@test-service", "tenant123", "user12345") - # "@test-service" -> "test-service" (leading @ stripped) - assert name.startswith("mcp-test") - assert not name.startswith("mcp-@") - - def test_generate_pod_name_trailing_special_chars(self, k8s_container_client): - """Test pod name generation with trailing special characters""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "test-service@", "tenant123", "user12345") - assert name == "mcp-test-service-tenant12-user1234-a1b2c3d4" - assert name.endswith("-a1b2c3d4") - - def test_generate_pod_name_uppercase(self, k8s_container_client): - """Test pod name generation with uppercase letters""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "TestService", "tenant123", "user12345") - assert name == "mcp-testservice-tenant12-user1234-a1b2c3d4" - - def test_generate_pod_name_underscores(self, k8s_container_client): - """Test pod name generation with underscores""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "test_service", "tenant_123", "user_12345") - # tenant_123 -> tenant-123 (9 chars), truncated to 8 -> tenant-1 - # user_12345 -> user-12345 (10 chars), truncated to 8 -> user-123 - assert name == "mcp-test-service-tenant-1-user-123-a1b2c3d4" - - def test_generate_pod_name_dots(self, k8s_container_client): - """Test pod name generation with dots""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "test.service", "tenant.123", "user.12345") - # tenant.123 -> tenant.123 (9 chars), truncated to 8 -> tenant.1 - # user.12345 -> user.12345 (10 chars), truncated to 8 -> user.123 - assert name == "mcp-test-service-tenant-1-user-123-a1b2c3d4" - - def test_generate_pod_name_spaces(self, k8s_container_client): - """Test pod name generation with spaces""" - with patch("nexent.container.k8s_client.uuid.uuid4") as mock_uuid: - mock_uuid.return_value.hex = "a1b2c3d4" - name = k8s_container_client._generate_pod_name( - "test service", "tenant 123", "user 12345") - # tenant 123 -> tenant 123 (9 chars), truncated to 8 -> tenant 1 - # user 12345 -> user 12345 (10 chars), truncated to 8 -> user 123 - assert name == "mcp-test-service-tenant-1-user-123-a1b2c3d4" - def test_generate_pod_name_long_user_id(self, k8s_container_client): """Test pod name generation with long user ID""" long_user_id = "a" * 20 @@ -356,7 +216,7 @@ def test_generate_pod_name_empty_tenant(self, k8s_container_client): mock_uuid.return_value.hex = "a1b2c3d4" name = k8s_container_client._generate_pod_name( "test-service", "", "user12345") - assert name == "mcp-test-service-unknown-user1234-a1b2c3d4" + assert name == "mcp-test-service--user1234-a1b2c3d4" def test_generate_pod_name_empty_user(self, k8s_container_client): """Test pod name generation with empty user_id""" @@ -364,7 +224,7 @@ def test_generate_pod_name_empty_user(self, k8s_container_client): mock_uuid.return_value.hex = "a1b2c3d4" name = k8s_container_client._generate_pod_name( "test-service", "tenant123", "") - assert name == "mcp-test-service-tenant12-unknown-a1b2c3d4" + assert name == "mcp-test-service-tenant12--a1b2c3d4" def test_generate_pod_name_none_tenant(self, k8s_container_client): """Test pod name generation with None tenant_id""" @@ -372,7 +232,7 @@ def test_generate_pod_name_none_tenant(self, k8s_container_client): mock_uuid.return_value.hex = "a1b2c3d4" name = k8s_container_client._generate_pod_name( "test-service", None, "user12345") - assert name == "mcp-test-service-unknown-user1234-a1b2c3d4" + assert name == "mcp-test-service--user1234-a1b2c3d4" def test_generate_pod_name_none_user(self, k8s_container_client): """Test pod name generation with None user_id""" @@ -380,7 +240,7 @@ def test_generate_pod_name_none_user(self, k8s_container_client): mock_uuid.return_value.hex = "a1b2c3d4" name = k8s_container_client._generate_pod_name( "test-service", "tenant123", None) - assert name == "mcp-test-service-tenant12-unknown-a1b2c3d4" + assert name == "mcp-test-service-tenant12--a1b2c3d4" # --------------------------------------------------------------------------- @@ -1405,26 +1265,6 @@ def test_list_containers_service_filter_special_chars(self, k8s_container_client assert len(result) == 0 - def test_list_containers_service_filter_consecutive_special_chars(self, k8s_container_client, mock_pod): - """Test listing containers with service filter containing consecutive special characters""" - k8s_container_client.core_v1.list_namespaced_pod.return_value = MagicMock(items=[mock_pod]) - - # The sanitized version of "test@@service" is "test-service" - # Since mock_pod's component is "test-service", it should match - result = k8s_container_client.list_containers(service_name="test@@service") - - assert len(result) == 1 - - def test_list_containers_service_filter_leading_special_chars(self, k8s_container_client, mock_pod): - """Test listing containers with service filter containing leading special characters""" - k8s_container_client.core_v1.list_namespaced_pod.return_value = MagicMock(items=[mock_pod]) - - # The sanitized version of "@test-service" is "test-service" (leading @ stripped) - # Since mock_pod's component is "test-service", it should match - result = k8s_container_client.list_containers(service_name="@test-service") - - assert len(result) == 1 - def test_list_containers_pod_no_ports(self, k8s_container_client): """Test listing containers when pod has no ports configured""" mock_pod_no_ports = MagicMock() diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py index 882e28514..ff8da11f8 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -939,88 +939,6 @@ def test_create_local_tool_knowledge_base_with_display_name_map(nexent_agent_ins assert result.rerank_model == "mock_rerank_model" -def test_create_local_tool_knowledge_base_with_document_paths_from_metadata(nexent_agent_instance): - """KnowledgeBaseSearchTool should receive document_paths from metadata via set_document_paths. - - The `document_paths` parameter is declared with `exclude=True` so it must not - be passed to __init__. Instead it must be forwarded to `set_document_paths` - on the instance, sourced from `tool_config.metadata`. This guards against - the FieldInfo-iteration regression reported when document_paths is unset. - """ - mock_kb_tool_class = MagicMock() - mock_kb_tool_instance = MagicMock() - mock_kb_tool_class.return_value = mock_kb_tool_instance - - document_paths = ["s3://bucket/doc1.txt", "s3://bucket/doc2.txt"] - - tool_config = ToolConfig( - class_name="KnowledgeBaseSearchTool", - name="knowledge_base_search", - description="desc", - inputs="{}", - output_type="string", - params={"top_k": 5, "index_names": ["kb1"]}, - source="local", - metadata={ - "vdb_core": "mock_vdb_core", - "embedding_model": "mock_embedding_model", - "document_paths": document_paths, - }, - ) - - original_value = nexent_agent.__dict__.get("KnowledgeBaseSearchTool") - nexent_agent.__dict__["KnowledgeBaseSearchTool"] = mock_kb_tool_class - - try: - nexent_agent_instance.create_local_tool(tool_config) - finally: - if original_value is not None: - nexent_agent.__dict__["KnowledgeBaseSearchTool"] = original_value - elif "KnowledgeBaseSearchTool" in nexent_agent.__dict__: - del nexent_agent.__dict__["KnowledgeBaseSearchTool"] - - # document_paths is excluded and must not be forwarded to __init__. - init_kwargs = mock_kb_tool_class.call_args.kwargs - assert "document_paths" not in init_kwargs - # It must instead be applied via set_document_paths on the instance. - mock_kb_tool_instance.set_document_paths.assert_called_once_with(document_paths) - - -def test_create_local_tool_knowledge_base_without_metadata_calls_set_document_paths_none(nexent_agent_instance): - """When metadata lacks document_paths, set_document_paths(None) must still be invoked. - - Ensures the tool's internal filter is explicitly reset to None rather than - left as a stale FieldInfo default from the smolagents wrapper. - """ - mock_kb_tool_class = MagicMock() - mock_kb_tool_instance = MagicMock() - mock_kb_tool_class.return_value = mock_kb_tool_instance - - tool_config = ToolConfig( - class_name="KnowledgeBaseSearchTool", - name="knowledge_base_search", - description="desc", - inputs="{}", - output_type="string", - params={"top_k": 5, "index_names": ["kb1"]}, - source="local", - metadata=None, - ) - - original_value = nexent_agent.__dict__.get("KnowledgeBaseSearchTool") - nexent_agent.__dict__["KnowledgeBaseSearchTool"] = mock_kb_tool_class - - try: - nexent_agent_instance.create_local_tool(tool_config) - finally: - if original_value is not None: - nexent_agent.__dict__["KnowledgeBaseSearchTool"] = original_value - elif "KnowledgeBaseSearchTool" in nexent_agent.__dict__: - del nexent_agent.__dict__["KnowledgeBaseSearchTool"] - - mock_kb_tool_instance.set_document_paths.assert_called_once_with(None) - - def test_create_local_tool_knowledge_base_with_empty_display_name_map(nexent_agent_instance): """Test KnowledgeBaseSearchTool creation handles empty display_name_to_index_map.""" mock_kb_tool_class = MagicMock() diff --git a/test/sdk/core/tools/test_knowledge_base_search_tool.py b/test/sdk/core/tools/test_knowledge_base_search_tool.py index 7a4b23ebe..acb94f43f 100644 --- a/test/sdk/core/tools/test_knowledge_base_search_tool.py +++ b/test/sdk/core/tools/test_knowledge_base_search_tool.py @@ -1776,91 +1776,3 @@ def test_forward_with_document_paths_filter_no_results_after_filter(self, mock_v assert "No results found" in str(excinfo.value) - def test_filter_by_document_paths_unwraps_fieldinfo_default(self, mock_vdb_core, mock_embedding_model): - """Filter should tolerate a FieldInfo default instead of a concrete list. - - Regression: smolagents' Tool wrapper does not expand FieldInfo defaults for - parameters declared with `exclude=True`, so `self._internal_document_paths` - may arrive as a FieldInfo. The filter must unwrap it instead of failing with - `TypeError: argument of type 'FieldInfo' is not iterable`. - """ - try: - from pydantic import FieldInfo - except ImportError: - from pydantic.fields import FieldInfo - - field_info_default = FieldInfo(default=["s3://bucket/doc1.txt"]) - - tool = KnowledgeBaseSearchTool( - index_names=["kb1"], - search_mode="hybrid", - vdb_core=mock_vdb_core, - embedding_model=mock_embedding_model, - document_paths=None, - ) - # Simulate a FieldInfo being assigned directly (e.g. from smolagents wrapper). - tool._internal_document_paths = field_info_default - - results = self._create_mock_formatted_results_with_paths( - ["s3://bucket/doc1.txt", "s3://bucket/doc2.txt"] - ) - filtered = tool._filter_by_document_paths(results) - - assert len(filtered) == 1 - assert filtered[0]["path_or_url"] == "s3://bucket/doc1.txt" - - def test_filter_by_document_paths_unwraps_fieldinfo_default_factory(self, mock_vdb_core, mock_embedding_model): - """Filter should tolerate a FieldInfo with default_factory.""" - try: - from pydantic import FieldInfo - except ImportError: - from pydantic.fields import FieldInfo - - field_info_factory = FieldInfo( - default_factory=lambda: ["s3://bucket/doc2.txt"] - ) - - tool = KnowledgeBaseSearchTool( - index_names=["kb1"], - search_mode="hybrid", - vdb_core=mock_vdb_core, - embedding_model=mock_embedding_model, - document_paths=None, - ) - tool._internal_document_paths = field_info_factory - - results = self._create_mock_formatted_results_with_paths( - ["s3://bucket/doc1.txt", "s3://bucket/doc2.txt"] - ) - filtered = tool._filter_by_document_paths(results) - - assert len(filtered) == 1 - assert filtered[0]["path_or_url"] == "s3://bucket/doc2.txt" - - def test_set_document_paths_unwraps_fieldinfo(self, mock_vdb_core, mock_embedding_model): - """set_document_paths should also accept FieldInfo input defensively.""" - try: - from pydantic import FieldInfo - except ImportError: - from pydantic.fields import FieldInfo - - tool = KnowledgeBaseSearchTool( - index_names=["kb1"], - search_mode="hybrid", - vdb_core=mock_vdb_core, - embedding_model=mock_embedding_model, - document_paths=None, - ) - - field_info = FieldInfo(default=["s3://bucket/doc1.txt"]) - tool.set_document_paths(field_info) - - results = self._create_mock_formatted_results_with_paths( - ["s3://bucket/doc1.txt", "s3://bucket/doc2.txt"] - ) - filtered = tool._filter_by_document_paths(results) - - assert len(filtered) == 1 - assert filtered[0]["path_or_url"] == "s3://bucket/doc1.txt" - -