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"
+
+