diff --git a/backend/consts/const.py b/backend/consts/const.py index a3a897043..574d550c0 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.0" +APP_VERSION = "v2.2.1" # Skill Creation Streaming Configuration diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 34db53525..0b7345461 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -227,7 +227,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=request.minio_files) + role=MESSAGE_ROLE["ASSISTANT"], message=message_list, minio_files=None) save_message(conversation_req, user_id=user_id, tenant_id=tenant_id) diff --git a/doc/docs/zh/user-guide/agent-development.md b/doc/docs/zh/user-guide/agent-development.md index 40805aeea..aa86b95b2 100644 --- a/doc/docs/zh/user-guide/agent-development.md +++ b/doc/docs/zh/user-guide/agent-development.md @@ -418,8 +418,9 @@ Content-Type: application/json ``` > 💡 **提示**: -> - 本地开发时,请将路径前面的 `/nb/a2a` 部分替换为 `http://localhost:5013/nb/a2a` +> - 本地开发时,如果使用docker启动:请将路径前面的 `/nb/a2a` 部分替换为 `http://localhost:5013/nb/a2a`;如果是如果通过k8s启动,请使用端口:30013 > - 生产环境请将路径替换为您的服务器域名或公网 IP 地址 +> - > ⚠️ **注意事项**: > - 调用 A2A Agent 需要在请求头中携带有效的认证信息 diff --git a/docker/sql/v2.2.0_0601_add_preserve_source_file_to_knowledge_record_t.sql b/docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql similarity index 100% rename from docker/sql/v2.2.0_0601_add_preserve_source_file_to_knowledge_record_t.sql rename to docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql diff --git a/docker/sql/v2.2.0_0603_add_greeting_fields_to_ag_tenant_agent_t.sql b/docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql similarity index 100% rename from docker/sql/v2.2.0_0603_add_greeting_fields_to_ag_tenant_agent_t.sql rename to docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql diff --git a/docker/sql/v2.2.0_0605_add_ag_agent_repository_t.sql b/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql similarity index 100% rename from docker/sql/v2.2.0_0605_add_ag_agent_repository_t.sql rename to docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql diff --git a/docker/sql/v2.2.0_0609_add_selected_agent_version_no_to_agent_relation_t.sql b/docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql similarity index 100% rename from docker/sql/v2.2.0_0609_add_selected_agent_version_no_to_agent_relation_t.sql rename to docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql diff --git a/frontend/app/[locale]/agents/components/a2a/A2AServerSettingsPanel.tsx b/frontend/app/[locale]/agents/components/a2a/A2AServerSettingsPanel.tsx index e15ff34ed..00d3e2b24 100644 --- a/frontend/app/[locale]/agents/components/a2a/A2AServerSettingsPanel.tsx +++ b/frontend/app/[locale]/agents/components/a2a/A2AServerSettingsPanel.tsx @@ -109,7 +109,7 @@ export default function A2AServerSettingsPanel({
{previewData.agentCardUrl} - {t("a2a.server.urlHint", { defaultValue: "Append base URL to access. For local dev: localhost:5013" })} + {t("a2a.server.urlHint", { defaultValue: "Append base URL to access. For local dev: localhost:5013(or 30013)" })}
@@ -162,10 +162,10 @@ export default function A2AServerSettingsPanel({ {t("a2a.server.usageTitle", { defaultValue: "How to use these endpoints" })}

- {t("a2a.server.localDevHint", { defaultValue: "For local development: prepend localhost:5013 to the paths above." })} + {t("a2a.server.localDevHint", { defaultValue: "For local development: prepend localhost:5013(or 30013) to the paths above." })}

- {t("a2a.server.productionHint", { defaultValue: "For production: replace localhost:5013 with your server domain or public IP and port 5013." })} + {t("a2a.server.productionHint", { defaultValue: "For production: replace localhost with your server domain or public IP." })}

diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index 24ec60616..cd46d2aa3 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 ?? true, + verificationEnabled: editedAgent.verification_config?.enabled ?? false, 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/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index c3ccbd6c0..a656e4071 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -2749,9 +2749,9 @@ "a2a.server.protocolVersion": "Protocol Version", "a2a.server.restEndpoints": "REST Endpoints", "a2a.server.usageTitle": "How to use these endpoints", - "a2a.server.localDevHint": "For local development: prepend localhost:5013/api to the paths above.", - "a2a.server.productionHint": "For production: replace localhost:5013 with your server domain or public IP and port 5013.", - "a2a.server.urlHint": "Append base URL to access. For local dev: localhost:5013/api", + "a2a.server.localDevHint": "For local development: prepend localhost:5013/api (docker), localhost:30013/api(k8s)", + "a2a.server.productionHint": "For production: replace localhost with your server domain or public IP.", + "a2a.server.urlHint": "Append base URL to access. For local dev: localhost:5013(or 30013)/api", "a2a.service.discoverFailed": "Failed to discover agent", "a2a.service.listFailed": "Failed to get list", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 09b8bcd4a..4180a1a2b 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -2791,9 +2791,9 @@ "a2a.server.protocolVersion": "协议版本", "a2a.server.restEndpoints": "HTTP+JSON (REST)", "a2a.server.usageTitle": "如何使用这些端点", - "a2a.server.localDevHint": "本地开发:在上述路径前加上 localhost:5013/api", - "a2a.server.productionHint": "生产环境:将 localhost:5013 替换为您的服务器域名或公网 IP 及端口 5013", - "a2a.server.urlHint": "需要拼接基础 URL 才能访问。本地开发请使用 localhost:5013/api", + "a2a.server.localDevHint": "本地开发:在上述路径前加上 localhost:5013/api(docker启动) localhost:30013/api(k8s启动)", + "a2a.server.productionHint": "生产环境:将 localhost 替换为您的服务器域名或公网 IP", + "a2a.server.urlHint": "需要拼接基础 URL 才能访问。本地开发请使用 localhost:5013(或30013)/api", "a2a.service.discoverFailed": "发现 Agent 失败", "a2a.service.listFailed": "获取列表失败", diff --git a/k8s/helm/deploy.sh b/k8s/helm/deploy.sh index 7a583307d..07522d22c 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 wait --for=condition=ready pod -l app=nexent-$svc -n $NAMESPACE --timeout=300s 2>/dev/null; then + if kubectl rollout status "deployment/nexent-$svc" -n "$NAMESPACE" --timeout=300s >/dev/null 2>&1; 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 a2f202b90..399c50917 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -1896,3 +1896,210 @@ 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 189292667..d6bb70a7f 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: 4Gi + memory: 64Gi cpu: 8 config: diff --git a/scripts/deployment/common.sh b/scripts/deployment/common.sh index 5855af1a0..006561553 100755 --- a/scripts/deployment/common.sh +++ b/scripts/deployment/common.sh @@ -319,11 +319,6 @@ 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 c1fa4db53..c2fb72741 100644 --- a/sdk/nexent/container/k8s_client.py +++ b/sdk/nexent/container/k8s_client.py @@ -8,6 +8,7 @@ import asyncio import logging import socket +import re import uuid import kubernetes @@ -23,6 +24,47 @@ 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""" @@ -77,9 +119,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 = "".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] + safe_name = _sanitize_k8s_name(service_name) + tenant_part = _sanitize_k8s_name(tenant_id)[:8] + user_part = _sanitize_k8s_name(user_id)[:8] uuid_part = uuid.uuid4().hex[:8] return f"mcp-{safe_name}-{tenant_part}-{user_part}-{uuid_part}" @@ -486,7 +528,7 @@ def list_containers( # Filter by service_name if provided if service_name: - safe_name = "".join(c if c.isalnum() or c == "-" else "-" for c in service_name) + safe_name = _sanitize_k8s_name(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 a9a31a94b..ed43b6691 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -198,11 +198,16 @@ 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 - # These parameters have exclude=True and cannot be passed to __init__ - # due to smolagents.tools.Tool wrapper restrictions + # 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. 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"]} + if k not in ["vdb_core", "embedding_model", "observer", + "rerank_model", "display_name_to_index_map", + "document_paths"]} # Create instance with only non-excluded parameters tools_obj = tool_class(**filtered_params) # Set excluded parameters directly as attributes after instantiation @@ -216,6 +221,13 @@ 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 9149ed05d..c0115a0ab 100644 --- a/sdk/nexent/core/tools/knowledge_base_search_tool.py +++ b/sdk/nexent/core/tools/knowledge_base_search_tool.py @@ -21,6 +21,21 @@ 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""" @@ -129,7 +144,10 @@ 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 - self._internal_document_paths = document_paths + # `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.record_ops = 1 self.running_prompt_zh = "知识库检索中..." @@ -144,7 +162,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 = document_paths + self._internal_document_paths = _unwrap_field_info(document_paths) def _convert_to_index_names(self, names: List[str]) -> List[str]: """Convert display names (knowledge_name) to index names if necessary. @@ -188,7 +206,7 @@ def _filter_by_document_paths(self, results: List[dict]) -> List[dict]: Returns: Filtered list containing only results with allowed document paths """ - allowed_paths = self._internal_document_paths + allowed_paths = _unwrap_field_info(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 42db8c58c..84e0bc557 100644 --- a/test/sdk/container/test_k8s_client.py +++ b/test/sdk/container/test_k8s_client.py @@ -11,6 +11,7 @@ KubernetesContainerClient, ContainerError, ContainerConnectionError, + _sanitize_k8s_name, ) from nexent.container.k8s_config import KubernetesContainerConfig @@ -90,6 +91,79 @@ 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__ # --------------------------------------------------------------------------- @@ -192,6 +266,72 @@ 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 @@ -216,7 +356,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--user1234-a1b2c3d4" + assert name == "mcp-test-service-unknown-user1234-a1b2c3d4" def test_generate_pod_name_empty_user(self, k8s_container_client): """Test pod name generation with empty user_id""" @@ -224,7 +364,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--a1b2c3d4" + assert name == "mcp-test-service-tenant12-unknown-a1b2c3d4" def test_generate_pod_name_none_tenant(self, k8s_container_client): """Test pod name generation with None tenant_id""" @@ -232,7 +372,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--user1234-a1b2c3d4" + assert name == "mcp-test-service-unknown-user1234-a1b2c3d4" def test_generate_pod_name_none_user(self, k8s_container_client): """Test pod name generation with None user_id""" @@ -240,7 +380,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--a1b2c3d4" + assert name == "mcp-test-service-tenant12-unknown-a1b2c3d4" # --------------------------------------------------------------------------- @@ -1265,6 +1405,26 @@ 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 ff8da11f8..882e28514 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -939,6 +939,88 @@ 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 acb94f43f..7a4b23ebe 100644 --- a/test/sdk/core/tools/test_knowledge_base_search_tool.py +++ b/test/sdk/core/tools/test_knowledge_base_search_tool.py @@ -1776,3 +1776,91 @@ 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" + +