{t("agentEvaluation.createSetModal.chooseFile")}
+{t("agentEvaluation.invalidAgentId")}
+ } onClick={() => router.push(backHref)}> + {t("common.back")} + ++ {t("agentEvaluation.pageDesc")} +
++ {t( + "chatInterface.shareManualCopyHint", + "The share link has been created. Copy it from the field below." + )} +
+ event.currentTarget.select()} + /> +")
lines.append(" result = run_skill_script(\"skill_name\", \"script_path\")")
lines.append(" print(result)")
@@ -193,13 +196,20 @@ def _format_skills_description(
lines.append(" result = run_skill_script(\"skill_name\", \"script_path\", \"--param1 value1 --flag\")")
lines.append(" print(result)")
lines.append(" ")
- lines.append(" 注意:只执行技能指南中明确声明的脚本路径,绝不自行构造脚本路径。")
+ lines.append(" 注意:")
+ lines.append(" - 只执行技能指南中明确声明的脚本路径,绝不自行构造脚本路径。")
+ lines.append(" - 不要把脚本当作当前工作目录(CWD)的相对路径处理;也不要使用绝对路径。")
+ lines.append(" - 当脚本不存在时,返回的错误信息中会列出该技能根目录下的可用脚本,请据此修正路径。")
lines.append("")
lines.append("5. **整合输出**:根据技能指南要求的输出格式,结合脚本执行结果生成最终回答。")
lines.append("")
- lines.append("6. **引用场景处理**:当技能内容中出现引用标记或需要引用其他文件时,需要识别并再次调用 read_skill_md:")
- lines.append(" - **引用模板识别**:注意技能内容中形如 `")
lines.append(" # 技能内容提示\"请参考 examples.md 获取详细示例\"")
@@ -235,7 +245,11 @@ def _format_skills_description(
lines.append("")
lines.append("3. **Follow Skill Guide**: After skill content is injected, strictly follow its steps. Do not skip steps or replace with your own code.")
lines.append("")
- lines.append("4. **Execute Skill Script**: If the skill guide references additional scripts (like ` `), call:")
+ lines.append("4. **Execute Skill Script**: Skill-internal references (for both documentation and scripts) may be declared with **any of the following equivalent forms** - treat them all the same way: ")
+ lines.append(" - XML tags: ` `, ` `")
+ lines.append(" - Single inline backticks: `` `scripts/analyze.py` ``, `` `reference/api_doc` ``")
+ lines.append(" - Triple-backtick fenced code blocks: `` ```scripts/analyze.py``` `` (only when the block body is a single path line)")
+ lines.append(" When calling `run_skill_script`, the `script_path` is **always resolved relative to the skill's root directory** (this is the platform behaviour, not the agent's CWD). Common forms:")
lines.append(" ")
lines.append(" result = run_skill_script(\"skill_name\", \"script_path\")")
lines.append(" print(result)")
@@ -246,13 +260,17 @@ def _format_skills_description(
lines.append(" result = run_skill_script(\"skill_name\", \"script_path\", \"--param1 value1 --flag\")")
lines.append(" print(result)")
lines.append(" ")
- lines.append(" Note: Only execute script paths explicitly declared in the skill guide. Never construct paths yourself.")
+ lines.append(" Note: Only execute script paths explicitly declared in the skill guide. Never construct paths yourself. Do not treat the script as relative to the current working directory (CWD), and never pass absolute paths. When the requested script cannot be found, the error returned by `run_skill_script` lists the scripts that *do* exist under the skill root - use it to correct the path.")
lines.append("")
lines.append("5. **Integrate Output**: Generate the final answer based on the skill guide's output format and script execution results.")
lines.append("")
- lines.append("6. **Handle References**: When the skill content has reference markers or needs to reference other files, identify and call read_skill_md again:")
- lines.append(" - **Reference template recognition**: Look for patterns like ` ` or natural-language references (\"see examples.md\", \"refer to reference/api_doc\")")
- lines.append(" - **Auto-complete**: After discovering a reference, try reading the referenced file for more info")
+ lines.append("6. **Handle References**: Skill-internal references can be expressed using XML tags, markdown forms, or natural-language hints. All three are **functionally equivalent** and must be recognised: ")
+ lines.append(" - **Reference patterns to recognise**:")
+ lines.append(" - XML tag form: ` `")
+ lines.append(" - Single inline backtick form: `` `examples.md` ``, `` `reference/api_doc` ``")
+ lines.append(" - Triple-backtick fenced block form: `` ```examples.md``` `` (only when the block body is a single path line)")
+ lines.append(" - Natural-language references (\"see examples.md\", \"refer to reference/api_doc\")")
+ lines.append(" - **Auto-complete**: After discovering a reference, call `read_skill_md(\"skill_name\", [\"\"])` only for the files you actually need. Do **not** load every referenced file blindly - decide based on the current task which references matter.")
lines.append(" - **Example**:")
lines.append(" ")
lines.append(" # Skill content says \"see examples.md for detailed examples\"")
@@ -487,7 +505,11 @@ def _format_skills_usage_requirements(
lines.append("### 技能使用要求")
lines.append("1. **技能优先**:如果用户请求匹配了某个技能的 description,必须先调用 `read_skill_md()` 加载技能指南,再按指南执行。不得跳过技能自行编写代码解决。")
lines.append("2. **忠实执行**:读取技能内容后,严格按技能指南中的步骤操作。不要自行修改流程、跳过步骤或用通用代码替代技能定义的流程。")
- lines.append("3. **脚本调用规范**:只使用 `run_skill_script` 工具执行技能指南中明确要求的脚本。传入的 `skill_name` 和 `script_path` 必须与技能指南中的声明完全一致,不要自行拼接或猜测路径。如果需要附加参数,将参数以命令行字符串形式传递给`run_skill_script`。")
+ lines.append("3. **脚本调用规范**:")
+ lines.append(" - 路径声明识别:技能指南中的脚本路径既可以以 XML 标签(` `)声明,也允许以等价的 Markdown 形式(`` `scripts/foo.py` `` 单反引号,或 `` ```scripts/foo.py``` `` 三重反引号代码块)声明。模型必须把这些形式都识别为脚本路径。")
+ lines.append(" - 路径解析:`run_skill_script` 的 `script_path` 参数**始终相对于技能根目录**解析,平台不会基于当前工作目录或绝对路径查找。请直接复用技能指南中的声明字符串,不要自行拼接或猜测路径。")
+ lines.append(" - 参数传递:如果需要附加参数,将参数以命令行字符串形式传递给 `run_skill_script`。")
+ lines.append(" - 错误回退:脚本不存在时,`run_skill_script` 返回的错误信息会列出当前技能根目录下可用的脚本路径,请据此修正。")
lines.append("4. **失败回退**:如果 `read_skill_md` 返回错误或 `run_skill_script` 执行失败,向用户说明情况,并尝试用通用推理模式提供替代方案。")
lines.append("5. **技能组合**:如果一个任务需要多个技能配合,按逻辑依赖顺序依次加载和执行,前一个技能的输出可作为后一个技能的输入。")
else:
@@ -496,7 +518,11 @@ def _format_skills_usage_requirements(
lines.append("### Skill Usage Requirements")
lines.append("1. **Skill Priority**: If a user request matches a skill's description, you must first call `read_skill_md()` to load the skill guide, then execute per the guide. Do not skip skills and write your own code.")
lines.append("2. **Faithful Execution**: After reading skill content, strictly follow the skill guide's steps. Do not modify the flow, skip steps, or replace with generic code.")
- lines.append("3. **Script Calling Specification**: Only use `run_skill_script` to execute scripts explicitly required in the skill guide. The `skill_name` and `script_path` must match the skill guide's declaration exactly. Do not construct or guess paths. For extra params, pass them as a command-line string to `run_skill_script`.")
+ lines.append("3. **Script Calling Specification**:")
+ lines.append(" - **Path declaration recognition**: A script path inside the skill guide may be declared using XML tags (` `) OR via the equivalent markdown forms - single inline backticks like `` `scripts/foo.py` ``, or triple-backtick fenced blocks like `` ```scripts/foo.py``` ``. Treat all three as the same kind of declaration.")
+ lines.append(" - **Path resolution**: The `script_path` argument of `run_skill_script` is **always resolved relative to the skill's root directory**. The platform will not look in the current working directory and will not follow absolute paths. Pass the path verbatim from the skill guide - never construct or guess a path.")
+ lines.append(" - **Parameter passing**: For extra parameters, pass them as a command-line string to `run_skill_script`.")
+ lines.append(" - **Error fallback**: When the script cannot be located, the error returned by `run_skill_script` lists the scripts that *do* exist under the skill root - use it to correct the path.")
lines.append("4. **Failure Fallback**: If `read_skill_md` returns an error or `run_skill_script` fails, explain to the user and try to provide an alternative via general reasoning mode.")
lines.append("5. **Skill Combination**: If a task needs multiple skills, load and execute in logical dependency order. The output of one skill can be input to the next.")
diff --git a/sdk/nexent/core/tools/run_skill_script_tool.py b/sdk/nexent/core/tools/run_skill_script_tool.py
index caff9aae7..b97b97fd9 100644
--- a/sdk/nexent/core/tools/run_skill_script_tool.py
+++ b/sdk/nexent/core/tools/run_skill_script_tool.py
@@ -50,9 +50,20 @@ def execute(
) -> str:
"""Execute a skill script with given parameters.
+ ``script_path`` is always resolved relative to the skill's root
+ directory (``/``), regardless of the
+ caller's working directory. The path may be supplied in any of the
+ forms an LLM might emit after reading a SKILL.md body - bare
+ relative paths (``scripts/analyze.py``), ``./`` prefixed paths, or
+ values extracted from inline backticks/fenced code blocks (with or
+ without surrounding quotes). If the script cannot be located the
+ returned error message lists the available scripts under the skill
+ to help diagnose the mistake.
+
Args:
skill_name: Name of the skill containing the script
- script_path: Path to script relative to skill directory (e.g., "scripts/analyze.py")
+ script_path: Path to script relative to skill directory
+ (e.g. ``scripts/analyze.py``).
params: Parameters to pass to the script as a raw string.
The string is appended directly to the command line.
@@ -117,14 +128,21 @@ def get_run_skill_script_tool(
def run_skill_script(skill_name: str, script_path: str, params: Optional[str] = None) -> str:
"""Execute a skill script with given parameters.
- This tool runs Python or shell scripts that are part of a skill.
- Scripts must be declared in the skill content using tags.
+ This tool runs Python or shell scripts that are part of a skill. Scripts
+ are declared in the skill via XML tags such as
+ `` ``. The ``script_path`` is always resolved
+ **relative to the skill's root directory**, not the agent's current
+ working directory. Common forms like ``scripts/foo`` (no extension) are
+ also accepted via .py/.sh fall-back resolution.
Args:
skill_name: Name of the skill containing the script (e.g., "code-reviewer")
- script_path: Path to the script relative to skill directory (e.g., "scripts/analyze.py")
+ script_path: Path to the script relative to the skill root directory
+ (e.g. ``"scripts/analyze.py"``, ``"./scripts/analyze.py"``,
+ ``"scripts/sub/run.sh"``). May be supplied bare or wrapped in
+ quotes if it was extracted from markdown formatting.
params: Raw command-line argument string to pass to the script.
- Example: "--target /path/to/file -c --code \"SELECT 1\""
+ Example: ``--target /path/to/file -c --code "SELECT 1"``
Returns:
Script execution result as string
diff --git a/sdk/nexent/skills/skill_manager.py b/sdk/nexent/skills/skill_manager.py
index c2e50c790..3efab49ed 100644
--- a/sdk/nexent/skills/skill_manager.py
+++ b/sdk/nexent/skills/skill_manager.py
@@ -731,11 +731,29 @@ def run_skill_script(
) -> Any:
"""Execute a skill script with given parameters.
+ The ``script_path`` is always resolved **relative to the skill's root
+ directory** (i.e. ``/``). This applies to
+ every caller - including tools that pass paths extracted from SKILL.md
+ inline backticks/code fences or ` ` tags. The
+ agent's current working directory and any absolute filesystem paths
+ are intentionally *not* honoured; use the skill-relative path that the
+ skill guide declares verbatim.
+
+ When the requested script cannot be found, the error message lists:
+
+ * every path that was tried (after normalisation),
+ * a search summary (absolute and skill-relative), and
+ * the scripts that *do* exist under the skill so the caller can
+ pick the right one.
+
Args:
skill_name: Name of the skill containing the script
- script_path: Path to script relative to skill directory (e.g., "scripts/analyze.py")
+ script_path: Path to script relative to the skill root directory
+ (e.g. ``scripts/analyze.py`` or ``scripts/sub/run.sh``).
+ Leading ``./`` or ``\\`` and surrounding whitespace are
+ stripped. The path may use forward slashes or backslashes.
params: Raw command-line argument string to pass to the script.
- Example: "--target /path/to/file -c --code \"SELECT 1\""
+ Example: ``--target /path/to/file -c --code "SELECT 1"``
agent_id: Agent ID for DB-based available skills lookup
tenant_id: Tenant ID for DB-based available skills lookup
version_no: Version number for DB-based available skills lookup
@@ -751,29 +769,119 @@ def run_skill_script(
if not os.path.isdir(local_skill_dir):
raise SkillNotFoundError(f"Skill '{skill_name}' not found.")
- normalized_script_path = script_path.replace("/", os.sep).replace("\\", os.sep)
- full_path = os.path.normpath(os.path.join(local_skill_dir, normalized_script_path))
- if not os.path.isfile(full_path):
- # List available scripts directly from local directory (no temp needed)
- available = []
- scripts_dir = os.path.join(local_skill_dir, "scripts")
- if os.path.isdir(scripts_dir):
- for root, _, files in os.walk(scripts_dir):
- for f in files:
- if f.endswith((".py", ".sh")):
- rel = os.path.relpath(os.path.join(root, f), local_skill_dir)
- available.append(rel)
- raise SkillScriptNotFoundError(
- f"Script '{script_path}' not found in skill '{skill_name}'. "
- f"Available scripts: {available if available else 'none'}"
+ # Normalise the incoming path: collapse whitespace, strip a leading
+ # "./" or "/" that the caller may have added by mistake, and convert
+ # backslashes to the platform separator so we can join it cleanly.
+ if script_path is None:
+ normalised_script_path = ""
+ else:
+ normalised_script_path = script_path.strip()
+
+ # Strip surrounding quotes (`"foo.py"` / `'foo.py'`) that sometimes
+ # leak from backtick or code-fence extraction. This is a best-effort
+ # helper for the LLM and does not handle arbitrarily escaped strings.
+ if (
+ len(normalised_script_path) >= 2
+ and normalised_script_path[0] == normalised_script_path[-1]
+ and normalised_script_path[0] in ("'", '"')
+ ):
+ normalised_script_path = normalised_script_path[1:-1].strip()
+
+ normalised_script_path = normalised_script_path.strip()
+ # Drop a leading relative-path marker like "./" so the join below is
+ # robust against callers that prepend "./scripts/foo.py".
+ while normalised_script_path.startswith(("./", ".\\")):
+ normalised_script_path = normalised_script_path[2:]
+ normalised_script_path = normalised_script_path.lstrip("/\\")
+ normalised_script_path = normalised_script_path.replace("/", os.sep).replace("\\", os.sep)
+ # Reject obvious path-traversal attempts: the script must live inside
+ # the skill directory. We do this by checking the absolute resolved
+ # path stays under the skill root after normalisation.
+ full_path = os.path.normpath(os.path.join(local_skill_dir, normalised_script_path))
+
+ # Build the friendly error up-front so we can attach diagnostic info
+ # whether the path is missing or escapes the skill root.
+ available = self._list_available_scripts(local_skill_dir)
+ tries: List[str] = [full_path]
+ tried_reasons: List[str] = []
+
+ def _fail(reason: str) -> "SkillScriptNotFoundError":
+ tried_reasons.append(reason)
+ return SkillScriptNotFoundError(
+ f"Script '{script_path}' not found in skill '{skill_name}'.\n"
+ f" - reason: {reason}\n"
+ f" - resolved to: {full_path}\n"
+ f" - skill root: {local_skill_dir}\n"
+ f" - tried: {', '.join(tries)}\n"
+ f" - available scripts: {available if available else 'none'}"
)
- if script_path.endswith(".py"):
+ # Disallow traversing outside the skill root.
+ skill_root_abs = os.path.abspath(local_skill_dir)
+ full_abs = os.path.abspath(full_path)
+ if not (full_abs == skill_root_abs or full_abs.startswith(skill_root_abs + os.sep)):
+ raise _fail("resolved path escapes the skill root directory")
+
+ if os.path.isfile(full_path):
+ pass
+ else:
+ # Try a couple of common fall-backs so that ``scripts/foo`` finds
+ # ``scripts/foo.py`` and ``scripts/foo.sh`` when the extension is
+ # omitted, but only when the variant stays inside the skill root.
+ base, ext = os.path.splitext(full_path)
+ if not ext:
+ for candidate_ext in (".py", ".sh"):
+ candidate = base + candidate_ext
+ candidate_abs = os.path.abspath(candidate)
+ if (
+ candidate_abs == skill_root_abs
+ or candidate_abs.startswith(skill_root_abs + os.sep)
+ ) and os.path.isfile(candidate):
+ full_path = candidate
+ normalised_script_path = normalised_script_path + candidate_ext
+ tries.append(candidate)
+ break
+ else:
+ raise _fail("script file does not exist (and no .py/.sh fall-back matched)")
+ else:
+ raise _fail("script file does not exist")
+
+ if normalised_script_path.endswith(".py"):
return self._run_python_script(full_path, params)
- elif script_path.endswith(".sh"):
+ elif normalised_script_path.endswith(".sh"):
return self._run_shell_script(full_path, params)
else:
- raise ValueError(f"Unsupported script type: {script_path}")
+ raise ValueError(f"Unsupported script type: {normalised_script_path}")
+
+ def _list_available_scripts(self, local_skill_dir: str) -> List[str]:
+ """Return script paths (relative to the skill root) that exist on disk.
+
+ Args:
+ local_skill_dir: Absolute path to the skill's local directory.
+
+ Returns:
+ Sorted list of script paths relative to ``local_skill_dir``. An
+ empty list is returned when the directory does not exist.
+ """
+ available: List[str] = []
+ scripts_dir = os.path.join(local_skill_dir, "scripts")
+ for root in (local_skill_dir, scripts_dir):
+ if not os.path.isdir(root):
+ continue
+ for dirpath, _dirs, files in os.walk(root):
+ for f in files:
+ if f.endswith((".py", ".sh")):
+ rel = os.path.relpath(os.path.join(dirpath, f), local_skill_dir)
+ available.append(rel.replace("\\", "/"))
+ # Deduplicate while keeping order.
+ seen = set()
+ unique: List[str] = []
+ for rel in available:
+ if rel in seen:
+ continue
+ seen.add(rel)
+ unique.append(rel)
+ return sorted(unique)
def _run_python_script(self, script_path: str, params: Optional[str]) -> str:
"""Run a Python script with parameters.
diff --git a/sdk/nexent/vector_database/elasticsearch_core.py b/sdk/nexent/vector_database/elasticsearch_core.py
index e8f6ec81a..408a5ad5a 100644
--- a/sdk/nexent/vector_database/elasticsearch_core.py
+++ b/sdk/nexent/vector_database/elasticsearch_core.py
@@ -1161,79 +1161,6 @@ def hybrid_search(
f"Warning: Missing required field in semantic result: {e}")
continue
- # FIX: For chunks that are in accurate results but not in semantic results,
- # generate embeddings and store them in ES, then re-execute semantic search
- # This handles chunks that were manually added without going through normal embedding pipeline
- accurate_doc_ids = set(r.get("document", {}).get("id") for r in accurate_results)
- semantic_doc_ids = set(r.get("document", {}).get("id") for r in semantic_results)
- missing_embedding_doc_ids = accurate_doc_ids - semantic_doc_ids
-
- if missing_embedding_doc_ids:
- logger.info(
- f"Found {len(missing_embedding_doc_ids)} chunks without stored embeddings, "
- f"generating and storing embeddings in ES: {missing_embedding_doc_ids}")
-
- # Process each chunk with missing embedding
- for doc_id in missing_embedding_doc_ids:
- if doc_id in combined_results:
- chunk_doc = combined_results[doc_id]
- chunk_content = chunk_doc["document"].get("content", "")
- index_name = chunk_doc.get("index", "")
-
- if chunk_content and index_name:
- # Generate embedding for chunk content
- chunk_embedding = embedding_model.get_embeddings(chunk_content)
- if chunk_embedding and len(chunk_embedding) > 0:
- # Update the document in ES with the embedding
- update_doc = chunk_doc["document"].copy()
- update_doc["embedding"] = chunk_embedding[0]
- if "embedding_model_name" not in update_doc:
- update_doc["embedding_model_name"] = embedding_model.embedding_model_name
-
- try:
- # Use create_chunk to store the chunk with embedding
- self.client.index(
- index=index_name,
- id=doc_id,
- document=update_doc,
- refresh="wait_for"
- )
- logger.debug(
- f"Stored embedding for chunk {doc_id} in index {index_name}")
- except Exception as e:
- logger.warning(
- f"Failed to store embedding for chunk {doc_id}: {e}")
- continue
-
- # Re-execute semantic search now that ES has the new embeddings
- logger.debug("Re-executing semantic search with updated embeddings")
- semantic_results = self.semantic_search(
- index_names, query_text, embedding_model=embedding_model, top_k=top_k)
-
- # Clear and re-process semantic results with the new embeddings
- # Remove old entries that came from accurate results
- for doc_id in list(combined_results.keys()):
- if doc_id in accurate_doc_ids:
- combined_results[doc_id]["semantic_score"] = 0
-
- # Process updated semantic results
- for result in semantic_results:
- try:
- doc_id = result["document"]["id"]
- if doc_id in combined_results:
- combined_results[doc_id]["semantic_score"] = result.get("score", 0)
- else:
- combined_results[doc_id] = {
- "document": result["document"],
- "accurate_score": 0,
- "semantic_score": result.get("score", 0),
- "index": result["index"],
- }
- except KeyError as e:
- logger.warning(
- f"Warning: Missing required field in semantic result: {e}")
- continue
-
# Calculate maximum scores
max_accurate = max([r.get("score", 0)
for r in accurate_results]) if accurate_results else 1
diff --git a/test/backend/utils/test_context_utils.py b/test/backend/utils/test_context_utils.py
index 92629a6fa..d41d7f513 100644
--- a/test/backend/utils/test_context_utils.py
+++ b/test/backend/utils/test_context_utils.py
@@ -33,6 +33,113 @@ class MockTool:
assert "search" in result
assert "Search tool" in result
+ def test_format_tools_single_english(self):
+ from backend.utils.context_utils import _format_tools_description
+ class MockTool:
+ name = "search"
+ description = "Search tool"
+ inputs = '{"query": "str"}'
+ output_type = "string"
+ source = "local"
+ result = _format_tools_description({"search": MockTool()}, language="en")
+ assert "search" in result
+ assert "Search tool" in result
+ assert "1. Tools" in result
+ assert "Accepts input" in result
+
+ def test_format_tools_dict_input(self):
+ """When tool is a plain dict, the dict-style branch should be exercised."""
+ from backend.utils.context_utils import _format_tools_description
+ tool_dict = {
+ "description": "Dict tool",
+ "inputs": '{"key": "value"}',
+ "output_type": "str",
+ "source": "local",
+ }
+ result = _format_tools_description({"dicttool": tool_dict}, language="zh")
+ assert "dicttool" in result
+ assert "Dict tool" in result
+ assert "接受输入" in result
+
+ def test_format_tools_dict_input_english(self):
+ from backend.utils.context_utils import _format_tools_description
+ tool_dict = {
+ "description": "Dict tool",
+ "inputs": '{"key": "value"}',
+ "output_type": "str",
+ "source": "local",
+ }
+ result = _format_tools_description({"dicttool": tool_dict}, language="en")
+ assert "dicttool" in result
+ assert "Dict tool" in result
+ assert "Accepts input" in result
+
+ def test_format_tools_mcp_source_zh(self):
+ """MCP source should render with [MCP] prefix in Chinese."""
+ from backend.utils.context_utils import _format_tools_description
+ class MockTool:
+ name = "mcp_tool"
+ description = "MCP description"
+ inputs = '{"x": "y"}'
+ output_type = "string"
+ source = "mcp"
+ result = _format_tools_description({"mcp_tool": MockTool()}, language="zh")
+ assert "[MCP]" in result
+ assert "mcp_tool" in result
+ assert "MCP description" in result
+
+ def test_format_tools_mcp_source_en(self):
+ from backend.utils.context_utils import _format_tools_description
+ class MockTool:
+ name = "mcp_tool"
+ description = "MCP description"
+ inputs = '{"x": "y"}'
+ output_type = "string"
+ source = "mcp"
+ result = _format_tools_description({"mcp_tool": MockTool()}, language="en")
+ assert "[MCP]" in result
+ assert "mcp_tool" in result
+ assert "MCP description" in result
+
+ def test_format_tools_managed_zh(self):
+ """When is_manager=False, the [MCP] guidance uses presigned_url."""
+ from backend.utils.context_utils import _format_tools_description
+ result = _format_tools_description(
+ {}, language="zh", is_manager=False
+ )
+ # The empty branch is shared for both manager and managed; this confirms
+ # the empty tools path works in managed mode.
+ assert "1. 工具" in result
+
+ def test_format_tools_file_url_guide_managed_zh(self):
+ """Non-empty tools + is_manager=False surfaces presigned_url guidance in Chinese."""
+ from backend.utils.context_utils import _format_tools_description
+ class MockTool:
+ name = "search"
+ description = "Search tool"
+ inputs = '{"query": "str"}'
+ output_type = "string"
+ source = "local"
+ result = _format_tools_description(
+ {"search": MockTool()}, language="zh", is_manager=False
+ )
+ assert "presigned_url" in result
+ assert "Download URL" not in result
+
+ def test_format_tools_file_url_guide_managed_en(self):
+ from backend.utils.context_utils import _format_tools_description
+ class MockTool:
+ name = "search"
+ description = "Search tool"
+ inputs = '{"query": "str"}'
+ output_type = "string"
+ source = "local"
+ result = _format_tools_description(
+ {"search": MockTool()}, language="en", is_manager=False
+ )
+ assert "presigned_url" in result
+ assert "Download URL" not in result
+
def test_format_skills_empty(self):
from backend.utils.context_utils import _format_skills_description
result = _format_skills_description([], language="zh")
@@ -45,6 +152,26 @@ def test_format_skills_single(self):
assert "skill1" in result
assert "Test skill" in result
+ def test_format_skills_english(self):
+ from backend.utils.context_utils import _format_skills_description
+ skills = [{"name": "skill1", "description": "Test skill"}]
+ result = _format_skills_description(skills, language="en")
+ assert "skill1" in result
+ assert "Test skill" in result
+ assert "Available Skills" in result
+ assert "Skill Usage Process" in result
+
+ def test_format_skills_multiple(self):
+ """Multiple skills should all appear in the rendered block."""
+ from backend.utils.context_utils import _format_skills_description
+ skills = [
+ {"name": "alpha", "description": "first"},
+ {"name": "beta", "description": "second"},
+ ]
+ result = _format_skills_description(skills, language="en")
+ assert "alpha " in result
+ assert "beta " in result
+
def test_format_memory_empty(self):
from backend.utils.context_utils import _format_memory_context
result = _format_memory_context([], language="zh")
@@ -62,6 +189,67 @@ def test_format_memory_string(self):
result = _format_memory_context(memory, language="zh")
assert "simple string" in result
+ def test_format_memory_english(self):
+ from backend.utils.context_utils import _format_memory_context
+ memory = [{"memory": "english memory", "memory_level": "user", "score": 0.7}]
+ result = _format_memory_context(memory, language="en")
+ assert "english memory" in result
+ assert "Contextual Memory" in result
+ assert "Memory Usage Guidelines" in result
+
+ def test_format_memory_all_levels(self):
+ """All four memory levels should appear in level order."""
+ from backend.utils.context_utils import _format_memory_context
+ memory = [
+ {"memory": "agent mem", "memory_level": "agent", "score": 0.1},
+ {"memory": "tenant mem", "memory_level": "tenant", "score": 0.9},
+ {"memory": "user_agent mem", "memory_level": "user_agent", "score": 0.8},
+ {"memory": "user mem", "memory_level": "user", "score": 0.5},
+ ]
+ result = _format_memory_context(memory, language="en")
+ # All four memories should be present.
+ assert "tenant mem" in result
+ assert "user_agent mem" in result
+ assert "user mem" in result
+ assert "agent mem" in result
+ # Tenant should appear before agent in the rendered output (level order).
+ assert result.index("tenant mem") < result.index("agent mem")
+
+ def test_format_memory_default_level(self):
+ """Memories without memory_level should default to 'user'."""
+ from backend.utils.context_utils import _format_memory_context
+ memory = [{"memory": "no level", "score": 0.5}]
+ result = _format_memory_context(memory, language="en")
+ assert "no level" in result
+
+ def test_format_memory_duplicate_levels(self):
+ """Two memories at the same level should be appended to the same bucket.
+ This exercises the 'level already in memory_by_level' branch on line 53->55."""
+ from backend.utils.context_utils import _format_memory_context
+ memory = [
+ {"memory": "first", "memory_level": "user", "score": 0.5},
+ {"memory": "second", "memory_level": "user", "score": 0.3},
+ ]
+ result = _format_memory_context(memory, language="en")
+ assert "first" in result
+ assert "second" in result
+
+ def test_format_memory_non_dict_ignored(self):
+ """Non-dict memory items should be ignored."""
+ from backend.utils.context_utils import _format_memory_context
+ memory = ["string-only", {"memory": "valid", "memory_level": "user", "score": 0.5}]
+ result = _format_memory_context(memory, language="en")
+ assert "valid" in result
+ # The string-only entry should not appear in the level-bucketed output.
+ assert "string-only" not in result
+
+ def test_format_memory_falls_back_to_content_key(self):
+ """When 'memory' key is missing, fall back to 'content' key."""
+ from backend.utils.context_utils import _format_memory_context
+ memory = [{"content": "fallback content", "memory_level": "user", "score": 0.3}]
+ result = _format_memory_context(memory, language="en")
+ assert "fallback content" in result
+
def test_format_managed_agents_empty(self):
from backend.utils.context_utils import _format_managed_agents_description
result = _format_managed_agents_description({}, language="zh")
@@ -75,6 +263,23 @@ class MockAgent:
result = _format_managed_agents_description({"research": MockAgent()}, language="zh")
assert "research" in result
+ def test_format_managed_agents_english(self):
+ from backend.utils.context_utils import _format_managed_agents_description
+ class MockAgent:
+ name = "research"
+ description = "Research assistant"
+ result = _format_managed_agents_description({"research": MockAgent()}, language="en")
+ assert "research" in result
+ assert "Research assistant" in result
+ assert "Internal agent calling specifications" in result
+
+ def test_format_managed_agents_dict_input(self):
+ from backend.utils.context_utils import _format_managed_agents_description
+ agent_dict = {"description": "Dict agent"}
+ result = _format_managed_agents_description({"a": agent_dict}, language="en")
+ assert "a" in result
+ assert "Dict agent" in result
+
def test_format_external_agents_empty(self):
from backend.utils.context_utils import _format_external_agents_description
result = _format_external_agents_description({}, language="zh")
@@ -89,6 +294,74 @@ class MockAgent:
result = _format_external_agents_description({"ext-1": MockAgent()}, language="zh")
assert "External" in result
+ def test_format_external_agents_english(self):
+ from backend.utils.context_utils import _format_external_agents_description
+ class MockAgent:
+ agent_id = "ext-1"
+ name = "External"
+ description = "External agent"
+ result = _format_external_agents_description({"ext-1": MockAgent()}, language="en")
+ assert "External" in result
+ assert "External agent" in result
+ assert "External agent calling specifications" in result
+
+ def test_format_external_agents_dict_input(self):
+ from backend.utils.context_utils import _format_external_agents_description
+ agent_dict = {"name": "ExtName", "description": "ExtDesc"}
+ result = _format_external_agents_description({"ext-1": agent_dict}, language="en")
+ assert "ExtName" in result
+ assert "ExtDesc" in result
+
+
+class TestFormatSkillsUsageRequirements:
+ def test_skills_usage_empty_zh(self):
+ from backend.utils.context_utils import _format_skills_usage_requirements
+ result = _format_skills_usage_requirements([], language="zh")
+ assert "3. 技能" in result
+ assert "当前没有可用的技能" in result
+
+ def test_skills_usage_empty_en(self):
+ from backend.utils.context_utils import _format_skills_usage_requirements
+ result = _format_skills_usage_requirements([], language="en")
+ assert "3. Skills" in result
+ assert "No skills are currently available" in result
+
+ def test_skills_usage_with_skills_zh(self):
+ from backend.utils.context_utils import _format_skills_usage_requirements
+ skills = [{"name": "a", "description": "b"}]
+ result = _format_skills_usage_requirements(skills, language="zh")
+ assert "技能使用要求" in result
+ assert "技能优先" in result
+
+ def test_skills_usage_with_skills_en(self):
+ from backend.utils.context_utils import _format_skills_usage_requirements
+ skills = [{"name": "a", "description": "b"}]
+ result = _format_skills_usage_requirements(skills, language="en")
+ assert "Skill Usage Requirements" in result
+ assert "Skill Priority" in result
+
+
+class TestFormatAgentFallback:
+ def test_agent_fallback_no_agents_zh(self):
+ from backend.utils.context_utils import _format_agent_fallback
+ result = _format_agent_fallback({}, {}, language="zh")
+ assert "当前没有可用的助手" in result
+
+ def test_agent_fallback_no_agents_en(self):
+ from backend.utils.context_utils import _format_agent_fallback
+ result = _format_agent_fallback({}, {}, language="en")
+ assert "No agents are currently available" in result
+
+ def test_agent_fallback_with_managed_agents(self):
+ from backend.utils.context_utils import _format_agent_fallback
+ result = _format_agent_fallback({"a": "x"}, {}, language="zh")
+ assert result == ""
+
+ def test_agent_fallback_with_external_agents(self):
+ from backend.utils.context_utils import _format_agent_fallback
+ result = _format_agent_fallback({}, {"a": "x"}, language="en")
+ assert result == ""
+
class TestBuildComponents:
def test_build_tools_component_empty(self):
@@ -107,6 +380,45 @@ class MockTool:
comp = build_tools_component({"tool": MockTool()}, language="zh")
assert len(comp.tools) == 1
+ def test_build_tools_component_with_dict_input(self):
+ """When tool is a dict, the dict-style branch should run."""
+ from backend.utils.context_utils import build_tools_component
+ tool_dict = {
+ "description": "Dict desc",
+ "inputs": "{}",
+ "output_type": "str",
+ "source": "local",
+ }
+ comp = build_tools_component({"tool": tool_dict}, language="en")
+ assert len(comp.tools) == 1
+ assert comp.tools[0]["name"] == "tool"
+ assert comp.tools[0]["description"] == "Dict desc"
+
+ def test_build_tools_component_mcp_source(self):
+ from backend.utils.context_utils import build_tools_component
+ class MockTool:
+ name = "m"
+ description = "mcp"
+ inputs = "{}"
+ output_type = "str"
+ source = "mcp"
+ comp = build_tools_component({"m": MockTool()}, language="zh")
+ assert comp.tools[0]["source"] == "mcp"
+
+ def test_build_tools_component_managed(self):
+ from backend.utils.context_utils import build_tools_component
+ class MockTool:
+ name = "t"
+ description = "d"
+ inputs = "{}"
+ output_type = "s"
+ source = "local"
+ comp = build_tools_component(
+ {"t": MockTool()}, language="zh", is_manager=False
+ )
+ assert len(comp.tools) == 1
+ assert "presigned_url" in comp.formatted_description
+
def test_build_skills_component_empty(self):
from backend.utils.context_utils import build_skills_component
comp = build_skills_component([], language="zh")
@@ -127,6 +439,45 @@ def test_build_memory_component_with_search_query(self):
comp = build_memory_component([], search_query="test query", language="zh")
assert comp.search_query == "test query"
+ def test_build_memory_component_with_string_items(self):
+ """String memory items should be wrapped as user memories."""
+ from backend.utils.context_utils import build_memory_component
+ comp = build_memory_component(["plain string"], language="zh")
+ assert len(comp.memories) == 1
+ assert comp.memories[0]["content"] == "plain string"
+ assert comp.memories[0]["memory_type"] == "user"
+
+ def test_build_memory_component_with_dict_items(self):
+ """Dict memory items should be transformed via the dict path."""
+ from backend.utils.context_utils import build_memory_component
+ mem = {
+ "memory": "alpha",
+ "content": "alpha-content",
+ "memory_type": "agent",
+ "metadata": {"src": "x"},
+ }
+ comp = build_memory_component([mem], language="en")
+ assert len(comp.memories) == 1
+ assert comp.memories[0]["content"] == "alpha"
+ assert comp.memories[0]["memory_type"] == "agent"
+ assert comp.memories[0]["metadata"] == {"src": "x"}
+
+ def test_build_memory_component_dict_falls_back_to_content(self):
+ from backend.utils.context_utils import build_memory_component
+ mem = {"content": "only-content"}
+ comp = build_memory_component([mem], language="en")
+ assert comp.memories[0]["content"] == "only-content"
+
+ def test_build_memory_component_non_dict_non_string_ignored(self):
+ """Memory items that are neither dict nor string are silently skipped.
+ This exercises the elif branch (line 994->987) when neither condition
+ is True."""
+ from backend.utils.context_utils import build_memory_component
+ # Pass an int and a list - neither is a dict or string, both should be
+ # skipped without raising.
+ comp = build_memory_component([123, [1, 2, 3]], language="en")
+ assert comp.memories == []
+
def test_build_knowledge_base_component_empty(self):
from backend.utils.context_utils import build_knowledge_base_component
comp = build_knowledge_base_component("")
@@ -138,16 +489,88 @@ def test_build_knowledge_base_component_with_summary(self):
assert "KB text" in comp.summary
assert "knowledge_base_search" in comp.summary
+ def test_build_knowledge_base_component_english(self):
+ from backend.utils.context_utils import build_knowledge_base_component
+ comp = build_knowledge_base_component("KB text", language="en")
+ assert "KB text" in comp.summary
+ assert "knowledge_base_search" in comp.summary
+ assert "based on the user's question" in comp.summary
+
+ def test_build_knowledge_base_component_no_kb_ids(self):
+ from backend.utils.context_utils import build_knowledge_base_component
+ comp = build_knowledge_base_component("KB text")
+ assert comp.kb_ids == []
+
def test_build_managed_agents_component_empty(self):
from backend.utils.context_utils import build_managed_agents_component
comp = build_managed_agents_component({}, language="zh")
assert comp.agents == []
+ def test_build_managed_agents_component_with_agents(self):
+ from backend.utils.context_utils import build_managed_agents_component
+ class MockAgent:
+ description = "Agent description"
+ tools = []
+ comp = build_managed_agents_component({"a": MockAgent()}, language="en")
+ assert len(comp.agents) == 1
+ assert comp.agents[0]["name"] == "a"
+
+ def test_build_managed_agents_component_with_tools(self):
+ from backend.utils.context_utils import build_managed_agents_component
+ class MockTool:
+ name = "search"
+ class MockAgent:
+ description = "Agent"
+ tools = [MockTool()]
+ comp = build_managed_agents_component({"a": MockAgent()}, language="en")
+ assert comp.agents[0]["tools"] == ["search"]
+
+ def test_build_managed_agents_component_dict_input(self):
+ from backend.utils.context_utils import build_managed_agents_component
+ agent_dict = {"description": "Dict agent"}
+ comp = build_managed_agents_component({"a": agent_dict}, language="en")
+ assert len(comp.agents) == 1
+ assert comp.agents[0]["name"] == "a"
+ assert comp.agents[0]["description"] == "Dict agent"
+
def test_build_external_agents_component_empty(self):
from backend.utils.context_utils import build_external_agents_component
comp = build_external_agents_component({}, language="zh")
assert comp.agents == []
+ def test_build_external_agents_component_with_agents(self):
+ from backend.utils.context_utils import build_external_agents_component
+ class MockAgent:
+ agent_id = 42
+ name = "Ext"
+ description = "Ext desc"
+ url = "https://example.com"
+ comp = build_external_agents_component({"42": MockAgent()}, language="en")
+ assert len(comp.agents) == 1
+ assert comp.agents[0]["agent_id"] == "42"
+ assert comp.agents[0]["name"] == "Ext"
+ assert comp.agents[0]["url"] == "https://example.com"
+
+ def test_build_external_agents_component_no_url(self):
+ from backend.utils.context_utils import build_external_agents_component
+ class MockAgent:
+ agent_id = 1
+ name = "Ext"
+ description = "Ext desc"
+ comp = build_external_agents_component({"1": MockAgent()}, language="en")
+ assert comp.agents[0]["url"] == ""
+
+ def test_build_external_agents_component_dict_input(self):
+ from backend.utils.context_utils import build_external_agents_component
+ agent_dict = {
+ "name": "DictExt",
+ "description": "Dict ext",
+ "url": "https://dict.example.com",
+ }
+ comp = build_external_agents_component({"1": agent_dict}, language="en")
+ assert comp.agents[0]["name"] == "DictExt"
+ assert comp.agents[0]["url"] == "https://dict.example.com"
+
def test_build_system_prompt_component_empty(self):
from backend.utils.context_utils import build_system_prompt_component
comp = build_system_prompt_component("")
@@ -159,6 +582,292 @@ def test_build_system_prompt_component_with_template(self):
assert comp.template_name == "template.yaml"
+class TestSkeletonComponents:
+ def test_skeleton_header_zh(self):
+ from backend.utils.context_utils import build_skeleton_header_component
+ comp = build_skeleton_header_component(
+ app_name="Nexent",
+ app_description="Platform",
+ user_id="u-1",
+ language="zh",
+ )
+ assert "Nexent" in comp.content
+ assert "Platform" in comp.content
+ assert "### 基本信息" in comp.content
+ assert comp.template_name == "header"
+ assert comp.priority == 100
+
+ def test_skeleton_header_en(self):
+ from backend.utils.context_utils import build_skeleton_header_component
+ comp = build_skeleton_header_component(
+ app_name="Nexent",
+ app_description="Platform",
+ user_id="u-1",
+ language="en",
+ )
+ assert "Nexent" in comp.content
+ assert "### Basic Information" in comp.content
+ assert "You are Nexent" in comp.content
+
+ def test_skeleton_header_custom_priority(self):
+ from backend.utils.context_utils import build_skeleton_header_component
+ comp = build_skeleton_header_component(
+ app_name="A", app_description="B", user_id="C", priority=42,
+ )
+ assert comp.priority == 42
+
+ def test_skeleton_duty_zh_manager(self):
+ from backend.utils.context_utils import build_skeleton_duty_component
+ comp = build_skeleton_duty_component(
+ duty="Help users.", language="zh", is_manager=True
+ )
+ assert "Help users." in comp.content
+ assert "### 核心职责" in comp.content
+ assert "行为安全" in comp.content
+ assert "文件操作必须使用平台提供的专用工具" in comp.content
+
+ def test_skeleton_duty_zh_managed(self):
+ """Managed agent uses different safety principles in Chinese."""
+ from backend.utils.context_utils import build_skeleton_duty_component
+ comp = build_skeleton_duty_component(
+ duty="Be helpful.", language="zh", is_manager=False
+ )
+ assert "Be helpful." in comp.content
+ assert "严禁直接执行代码进行文件的增删改操作" in comp.content
+
+ def test_skeleton_duty_en(self):
+ from backend.utils.context_utils import build_skeleton_duty_component
+ comp = build_skeleton_duty_component(
+ duty="Help.", language="en", is_manager=True
+ )
+ assert "### Core Responsibilities" in comp.content
+ assert "Behavioral Safety" in comp.content
+
+ def test_skeleton_duty_custom_priority(self):
+ from backend.utils.context_utils import build_skeleton_duty_component
+ comp = build_skeleton_duty_component("d", priority=99)
+ assert comp.priority == 99
+
+ def test_skeleton_execution_flow_zh_manager(self):
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=None, language="zh", is_manager=True
+ )
+ assert "### 执行流程" in comp.content
+ assert "思考" in comp.content
+ # Manager: 分析当前任务状态和进展
+ assert "分析当前任务状态" in comp.content
+
+ def test_skeleton_execution_flow_zh_managed(self):
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=None, language="zh", is_manager=False
+ )
+ assert "确定需要使用哪些工具" in comp.content
+ # Non-manager: includes 语义连贯 hint
+ assert "语义连贯" in comp.content
+
+ def test_skeleton_execution_flow_en_manager(self):
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=None, language="en", is_manager=True
+ )
+ assert "### Execution Process" in comp.content
+ assert "Think:" in comp.content
+ assert "Analyze current task status" in comp.content
+
+ def test_skeleton_execution_flow_en_managed(self):
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=None, language="en", is_manager=False
+ )
+ assert "Determine which tools" in comp.content
+ assert "semantically coherent" in comp.content
+
+ def test_skeleton_execution_flow_zh_manager_with_memory(self):
+ """When memory_list is non-empty in the (legacy) call site, ZH manager shows
+ the memory reference line. Note: build_context_components always passes
+ memory_list=None to keep the stable prefix cache-friendly, so we call
+ this helper directly to exercise the legacy branch."""
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=[{"memory": "old", "memory_level": "user", "score": 0.5}],
+ language="zh",
+ is_manager=True,
+ )
+ assert "合理参考之前交互中的上下文记忆信息" in comp.content
+
+ def test_skeleton_execution_flow_en_manager_with_memory(self):
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=[{"memory": "old", "memory_level": "user", "score": 0.5}],
+ language="en",
+ is_manager=True,
+ )
+ assert "Reference relevant contextual memories" in comp.content
+
+ def test_skeleton_execution_flow_en_managed_with_memory(self):
+ """Non-manager also picks up the memory hint when memory is provided."""
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=[{"memory": "old", "memory_level": "user", "score": 0.5}],
+ language="en",
+ is_manager=False,
+ )
+ assert "Reference relevant contextual memories" in comp.content
+
+ def test_skeleton_execution_flow_empty_memory_list(self):
+ from backend.utils.context_utils import build_skeleton_execution_flow_component
+ comp = build_skeleton_execution_flow_component(
+ memory_list=[], language="zh", is_manager=True
+ )
+ # Empty list should not produce the memory reference line.
+ assert "合理参考之前交互中的上下文记忆信息" not in comp.content
+
+ def test_skeleton_constraint_zh(self):
+ from backend.utils.context_utils import build_skeleton_constraint_component
+ comp = build_skeleton_constraint_component("No smoking", language="zh")
+ assert "No smoking" in comp.content
+ assert "### 资源使用要求" in comp.content
+ assert comp.template_name == "constraint"
+
+ def test_skeleton_constraint_en(self):
+ from backend.utils.context_utils import build_skeleton_constraint_component
+ comp = build_skeleton_constraint_component("No smoking", language="en")
+ assert "### Resource Usage Requirements" in comp.content
+ assert "No smoking" in comp.content
+
+ def test_skeleton_constraint_custom_priority(self):
+ from backend.utils.context_utils import build_skeleton_constraint_component
+ comp = build_skeleton_constraint_component("x", priority=11)
+ assert comp.priority == 11
+
+ def test_skeleton_code_norms_zh_manager(self):
+ from backend.utils.context_utils import build_skeleton_code_norms_component
+ comp = build_skeleton_code_norms_component(language="zh", is_manager=True)
+ assert "### python代码规范" in comp.content
+ # Manager: 助手调用必须使用task参数
+ assert "助手调用必须使用task参数" in comp.content
+ # Skips the "8." item in Chinese (jumps from 7 to 9 in the rule list)
+ assert "9. 示例" in comp.content
+
+ def test_skeleton_code_norms_zh_managed(self):
+ from backend.utils.context_utils import build_skeleton_code_norms_component
+ comp = build_skeleton_code_norms_component(language="zh", is_manager=False)
+ # Non-manager ZH should NOT include the 11th rule about 助手调用.
+ assert "助手调用必须使用task参数" not in comp.content
+
+ def test_skeleton_code_norms_en_manager(self):
+ from backend.utils.context_utils import build_skeleton_code_norms_component
+ comp = build_skeleton_code_norms_component(language="en", is_manager=True)
+ assert "### Python Code Specifications" in comp.content
+ assert "Agent calls must use task parameter" in comp.content
+
+ def test_skeleton_code_norms_en_managed(self):
+ from backend.utils.context_utils import build_skeleton_code_norms_component
+ comp = build_skeleton_code_norms_component(language="en", is_manager=False)
+ assert "Agent calls must use task parameter" not in comp.content
+
+ def test_skeleton_code_norms_custom_priority(self):
+ from backend.utils.context_utils import build_skeleton_code_norms_component
+ comp = build_skeleton_code_norms_component(priority=5)
+ assert comp.priority == 5
+
+ def test_skeleton_footer_zh(self):
+ from backend.utils.context_utils import build_skeleton_footer_component
+ comp = build_skeleton_footer_component("Q: hi\nA: Hello", language="zh")
+ assert "### 示例模板" in comp.content
+ assert "Q: hi" in comp.content
+ assert "100万美元" in comp.content
+
+ def test_skeleton_footer_en(self):
+ from backend.utils.context_utils import build_skeleton_footer_component
+ comp = build_skeleton_footer_component("Q: hi", language="en")
+ assert "### Example Templates" in comp.content
+ assert "1 million dollars" in comp.content
+
+ def test_skeleton_footer_custom_priority(self):
+ from backend.utils.context_utils import build_skeleton_footer_component
+ comp = build_skeleton_footer_component("x", priority=1)
+ assert comp.priority == 1
+
+ def test_available_resources_header_zh_manager(self):
+ from backend.utils.context_utils import build_available_resources_header_component
+ comp = build_available_resources_header_component(is_manager=True, language="zh")
+ assert "### 可用资源" in comp.content
+ assert "你只能使用以下资源" in comp.content
+
+ def test_available_resources_header_zh_managed(self):
+ from backend.utils.context_utils import build_available_resources_header_component
+ comp = build_available_resources_header_component(is_manager=False, language="zh")
+ assert "### 可用资源" in comp.content
+ assert "你只能使用以下资源" not in comp.content
+
+ def test_available_resources_header_en_manager(self):
+ from backend.utils.context_utils import build_available_resources_header_component
+ comp = build_available_resources_header_component(is_manager=True, language="en")
+ assert "### Available Resources" in comp.content
+ assert "You can only use the following resources" in comp.content
+
+ def test_available_resources_header_en_managed(self):
+ from backend.utils.context_utils import build_available_resources_header_component
+ comp = build_available_resources_header_component(is_manager=False, language="en")
+ assert "### Available Resources" in comp.content
+ assert "You can only use the following resources" not in comp.content
+
+ def test_available_resources_header_custom_priority(self):
+ from backend.utils.context_utils import build_available_resources_header_component
+ comp = build_available_resources_header_component(priority=66)
+ assert comp.priority == 66
+
+
+class TestSkillsUsageAndAgentFallbackComponents:
+ def test_build_skills_usage_component_empty(self):
+ from backend.utils.context_utils import build_skills_usage_component
+ comp = build_skills_usage_component([], language="zh")
+ assert comp.skills == []
+ assert "当前没有可用的技能" in comp.formatted_description
+
+ def test_build_skills_usage_component_empty_en(self):
+ from backend.utils.context_utils import build_skills_usage_component
+ comp = build_skills_usage_component([], language="en")
+ assert "No skills are currently available" in comp.formatted_description
+
+ def test_build_skills_usage_component_with_skills(self):
+ from backend.utils.context_utils import build_skills_usage_component
+ skills = [{"name": "x", "description": "y"}]
+ comp = build_skills_usage_component(skills, language="en", is_manager=False)
+ assert comp.skills == skills
+ assert "Skill Usage Requirements" in comp.formatted_description
+
+ def test_build_skills_usage_component_custom_priority(self):
+ from backend.utils.context_utils import build_skills_usage_component
+ comp = build_skills_usage_component([], priority=33)
+ assert comp.priority == 33
+
+ def test_build_agent_fallback_component_no_agents_zh(self):
+ from backend.utils.context_utils import build_agent_fallback_component
+ comp = build_agent_fallback_component({}, {}, language="zh")
+ assert "当前没有可用的助手" in comp.content
+ assert comp.template_name == "agent_fallback"
+
+ def test_build_agent_fallback_component_no_agents_en(self):
+ from backend.utils.context_utils import build_agent_fallback_component
+ comp = build_agent_fallback_component({}, {}, language="en")
+ assert "No agents are currently available" in comp.content
+
+ def test_build_agent_fallback_component_with_agents(self):
+ """When agents are available, the fallback content is empty."""
+ from backend.utils.context_utils import build_agent_fallback_component
+ comp = build_agent_fallback_component({"a": "x"}, {}, language="zh")
+ assert comp.content == ""
+
+ def test_build_agent_fallback_component_custom_priority(self):
+ from backend.utils.context_utils import build_agent_fallback_component
+ comp = build_agent_fallback_component({}, {}, priority=99)
+ assert comp.priority == 99
+
+
class TestBuildContextComponents:
def test_empty_inputs_produces_skeleton(self):
from backend.utils.context_utils import build_context_components
@@ -227,6 +936,423 @@ def test_app_context_string(self):
assert "Platform" in result
assert "user-1" in result
+ def test_app_context_string_line_layout(self):
+ """_format_app_context emits 3 lines, one per field."""
+ from backend.utils.context_utils import build_app_context_string
+ result = build_app_context_string("A", "B", "C")
+ lines = result.split("\n")
+ assert lines[0] == "Application: A"
+ assert lines[1] == "Description: B"
+ assert lines[2] == "Current user: C"
+
+ def test_skips_header_when_app_info_missing(self):
+ """Header is only emitted when all three of name/description/user_id are set."""
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d",
+ app_name=None,
+ app_description="x",
+ user_id="u",
+ )
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "header" not in templates
+
+ def test_skips_header_when_user_id_missing(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d",
+ app_name="a",
+ app_description="b",
+ user_id=None,
+ )
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "header" not in templates
+
+ def test_memory_included_when_provided(self):
+ from backend.utils.context_utils import build_context_components
+ memory = [{"memory": "x", "memory_level": "user", "score": 0.5}]
+ components = build_context_components(
+ duty="d", memory_list=memory, memory_search_query="q"
+ )
+ types = [c.component_type for c in components]
+ assert "memory" in types
+
+ def test_memory_skipped_when_include_false(self):
+ from backend.utils.context_utils import build_context_components
+ memory = [{"memory": "x", "memory_level": "user", "score": 0.5}]
+ components = build_context_components(
+ duty="d", memory_list=memory, include_memory=False
+ )
+ types = [c.component_type for c in components]
+ assert "memory" not in types
+
+ def test_memory_skipped_when_empty(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="d", memory_list=[])
+ types = [c.component_type for c in components]
+ assert "memory" not in types
+
+ def test_skills_included_when_provided(self):
+ from backend.utils.context_utils import build_context_components
+ skills = [{"name": "s", "description": "d"}]
+ components = build_context_components(duty="d", skills=skills)
+ types = [c.component_type for c in components]
+ assert "skills" in types
+
+ def test_skills_skipped_when_include_false(self):
+ from backend.utils.context_utils import build_context_components
+ skills = [{"name": "s", "description": "d"}]
+ components = build_context_components(
+ duty="d", skills=skills, include_skills=False
+ )
+ # include_skills gates BOTH the skills component and the usage component
+ # (which is always emitted when include_skills is true, regardless of
+ # whether skills is non-empty). So we expect no "skills" component.
+ types = [c.component_type for c in components]
+ assert "skills" not in types
+
+ def test_knowledge_base_included_when_provided(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d", knowledge_base_summary="kb-text", kb_ids=["k1"]
+ )
+ types = [c.component_type for c in components]
+ assert "knowledge_base" in types
+
+ def test_knowledge_base_skipped_when_empty(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d", knowledge_base_summary=""
+ )
+ types = [c.component_type for c in components]
+ assert "knowledge_base" not in types
+
+ def test_knowledge_base_skipped_when_include_false(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d",
+ knowledge_base_summary="kb-text",
+ include_knowledge_base=False,
+ )
+ types = [c.component_type for c in components]
+ assert "knowledge_base" not in types
+
+ def test_managed_agents_included_for_manager(self):
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ description = "x"
+ components = build_context_components(
+ duty="d",
+ is_manager=True,
+ managed_agents={"a": MockAgent()},
+ )
+ types = [c.component_type for c in components]
+ assert "managed_agents" in types
+
+ def test_managed_agents_skipped_for_managed_agent(self):
+ """Non-manager agent should NOT include managed agents even when provided."""
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ description = "x"
+ components = build_context_components(
+ duty="d",
+ is_manager=False,
+ managed_agents={"a": MockAgent()},
+ )
+ types = [c.component_type for c in components]
+ assert "managed_agents" not in types
+
+ def test_managed_agents_skipped_when_include_false(self):
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ description = "x"
+ components = build_context_components(
+ duty="d",
+ is_manager=True,
+ managed_agents={"a": MockAgent()},
+ include_managed_agents=False,
+ )
+ types = [c.component_type for c in components]
+ assert "managed_agents" not in types
+
+ def test_external_agents_included_for_manager(self):
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ agent_id = 1
+ name = "x"
+ description = "y"
+ components = build_context_components(
+ duty="d",
+ is_manager=True,
+ external_a2a_agents={"1": MockAgent()},
+ )
+ types = [c.component_type for c in components]
+ assert "external_a2a_agents" in types
+
+ def test_external_agents_skipped_for_managed_agent(self):
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ agent_id = 1
+ name = "x"
+ description = "y"
+ components = build_context_components(
+ duty="d",
+ is_manager=False,
+ external_a2a_agents={"1": MockAgent()},
+ )
+ types = [c.component_type for c in components]
+ assert "external_a2a_agents" not in types
+
+ def test_external_agents_skipped_when_include_false(self):
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ agent_id = 1
+ name = "x"
+ description = "y"
+ components = build_context_components(
+ duty="d",
+ is_manager=True,
+ external_a2a_agents={"1": MockAgent()},
+ include_external_agents=False,
+ )
+ types = [c.component_type for c in components]
+ assert "external_a2a_agents" not in types
+
+ def test_fallback_when_no_agents_manager(self):
+ """When manager has no agents, the fallback component is added."""
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d", is_manager=True
+ )
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "agent_fallback" in templates
+
+ def test_no_fallback_for_managed_agent(self):
+ """Non-manager agent does not get the agent_fallback component even
+ without agents."""
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(
+ duty="d", is_manager=False
+ )
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "agent_fallback" not in templates
+
+ def test_no_fallback_when_agents_present(self):
+ """When manager has agents, the fallback component is not added."""
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ description = "x"
+ components = build_context_components(
+ duty="d",
+ is_manager=True,
+ managed_agents={"a": MockAgent()},
+ )
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "agent_fallback" not in templates
+
+ def test_constraint_skipped_when_empty(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="d", constraint="")
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "constraint" not in templates
+
+ def test_constraint_skipped_when_none(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="d", constraint=None)
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "constraint" not in templates
+
+ def test_few_shots_skipped_when_empty(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="d", few_shots="")
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "footer" not in templates
+
+ def test_few_shots_skipped_when_none(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="d", few_shots=None)
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "footer" not in templates
+
+ def test_duty_skipped_when_empty(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="")
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "duty" not in templates
+
+ def test_duty_skipped_when_none(self):
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty=None)
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "duty" not in templates
+
+ def test_execution_flow_always_emitted(self):
+ """Execution flow is emitted even when no duty/optional params provided."""
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components()
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "execution_flow" in templates
+ assert "available_resources_header" in templates
+ assert "code_norms" in templates
+
+ def test_full_assembly_includes_every_section(self):
+ """A full configuration should emit all 15 component types in order."""
+ from backend.utils.context_utils import build_context_components
+ class MockTool:
+ name = "tool"
+ description = "desc"
+ inputs = "{}"
+ output_type = "str"
+ source = "local"
+ class MockAgent:
+ description = "x"
+ tools = []
+ class MockExtAgent:
+ agent_id = 1
+ name = "ext"
+ description = "ext-desc"
+
+ memory = [{"memory": "x", "memory_level": "user", "score": 0.5}]
+ skills = [{"name": "s", "description": "d"}]
+
+ components = build_context_components(
+ duty="d",
+ constraint="c",
+ few_shots="f",
+ app_name="a",
+ app_description="b",
+ user_id="u",
+ language="en",
+ is_manager=True,
+ tools={"t": MockTool()},
+ skills=skills,
+ managed_agents={"m": MockAgent()},
+ external_a2a_agents={"1": MockExtAgent()},
+ memory_list=memory,
+ memory_search_query="q",
+ knowledge_base_summary="kb",
+ kb_ids=["k1"],
+ )
+
+ types = [c.component_type for c in components]
+ # Required sections all present.
+ assert "system_prompt" in types
+ assert "memory" in types
+ assert "skills" in types
+ assert "tools" in types
+ assert "knowledge_base" in types
+ assert "managed_agents" in types
+ assert "external_a2a_agents" in types
+ # No fallback when agents are present.
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "agent_fallback" not in templates
+ # Constraint and footer are present.
+ assert "constraint" in templates
+ assert "footer" in templates
+
+ def test_assembly_order_is_stable(self):
+ """The assembly order is fixed: header -> memory -> duty -> skills ->
+ execution_flow -> available_resources_header -> tools -> knowledge_base
+ -> managed_agents -> external_a2a_agents -> skills_usage -> constraint
+ -> code_norms -> footer."""
+ from backend.utils.context_utils import build_context_components
+ class MockAgent:
+ description = "x"
+ components = build_context_components(
+ duty="d",
+ few_shots="f",
+ app_name="a",
+ app_description="b",
+ user_id="u",
+ is_manager=True,
+ skills=[{"name": "s", "description": "d"}],
+ managed_agents={"m": MockAgent()},
+ )
+ # Find template-name sequence in order
+ template_order = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert template_order[0] == "header"
+ assert template_order[-1] == "footer"
+ # code_norms should be the second-to-last (right before footer)
+ assert template_order[-2] == "code_norms"
+
+ def test_skills_usage_emitted_even_when_no_skills(self):
+ """Skills usage component is emitted whenever include_skills=True,
+ regardless of whether skills are non-empty."""
+ from backend.utils.context_utils import build_context_components
+ components = build_context_components(duty="d", include_skills=True)
+ # The skills_usage component is built with skills=[] when no skills,
+ # so formatted_description should mention the empty case.
+ for c in components:
+ if c.component_type == "skills":
+ # Either the descriptive one or the usage one - just check it
+ # does not crash. This is mostly a smoke test for the branch.
+ assert c.formatted_description is not None
+
+ def test_fallback_skipped_when_content_empty(self, mocker):
+ """When the fallback component is built with empty content, the
+ ``if fallback_comp.content:`` guard prevents it from being appended.
+ This exercises the False branch of line 1408."""
+ from backend.utils.context_utils import build_context_components
+ from nexent.core.agents.agent_model import SystemPromptComponent
+
+ # Build a mock fallback component with empty content.
+ empty_fallback = SystemPromptComponent(
+ content="", template_name="agent_fallback", priority=5
+ )
+ mocker.patch(
+ "backend.utils.context_utils.build_agent_fallback_component",
+ return_value=empty_fallback,
+ )
+
+ components = build_context_components(duty="d", is_manager=True)
+ templates = [
+ c.template_name for c in components
+ if getattr(c, "template_name", None)
+ ]
+ assert "agent_fallback" not in templates
+
if __name__ == "__main__":
- pytest.main([__file__])
\ No newline at end of file
+ pytest.main([__file__])
diff --git a/test/sdk/skills/test_skill_manager.py b/test/sdk/skills/test_skill_manager.py
index b23c0b8dd..0f38aaaf3 100644
--- a/test/sdk/skills/test_skill_manager.py
+++ b/test/sdk/skills/test_skill_manager.py
@@ -1449,7 +1449,7 @@ def test_add_to_tree_empty_parts(self):
assert len(node["children"]) == 0
-class TestSkillManagerDeleteSkill:
+class TestSkillManagerDeleteSkillErrorHandling:
"""Test SkillManager.delete_skill error handling."""
def test_delete_skill_with_os_error(self, mocker):
@@ -1485,7 +1485,7 @@ def mock_rmtree(path, **kwargs):
assert result is True
-class TestSkillManagerBuildSkillsSummary:
+class TestSkillManagerBuildSkillsSummaryEdgeCases:
"""Test SkillManager.build_skills_summary edge cases."""
def test_build_summary_with_empty_description(self):
@@ -1510,7 +1510,7 @@ def test_build_summary_with_empty_description(self):
assert "empty-desc " in result
-class TestSkillManagerCleanupSkillDirectory:
+class TestSkillManagerCleanupSkillDirectoryOsError:
"""Test SkillManager.cleanup_skill_directory error handling."""
def test_cleanup_with_os_error(self, mocker):
@@ -1525,7 +1525,7 @@ def test_cleanup_with_os_error(self, mocker):
manager.cleanup_skill_directory("test")
-class TestSkillManagerRunSkillScript:
+class TestSkillManagerRunSkillScriptErrorHandling:
"""Test SkillManager.run_skill_script error handling."""
def test_run_python_script_timeout(self, mocker):
@@ -1631,7 +1631,7 @@ def test_run_shell_script_error_returns_json(self, mocker):
assert "error" in parsed
-class TestSkillManagerGetSkillFileTree:
+class TestSkillManagerGetSkillFileTreeEdgeCases:
"""Test SkillManager.get_skill_file_tree edge cases."""
def test_get_file_tree_includes_skill_md_in_subdirs(self):
@@ -1666,7 +1666,7 @@ def count_skill_md(node):
assert count_skill_md(result) == 2
-class TestSkillManagerListSkills:
+class TestSkillManagerListSkillsErrorHandling:
"""Test SkillManager.list_skills error handling."""
def test_list_skills_with_os_error(self, mocker):
@@ -2203,8 +2203,83 @@ def test_update_skill_md_from_bytes(self):
assert result is not None
assert result["description"] == "Updated from bytes"
+ def test_update_skill_auto_detects_zip(self):
+ """With file_type='auto' and PK magic bytes, code routes to ZIP update."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
-class TestSkillManagerLoadSkillDirectory:
+ temp.create_skill(
+ "auto-zip-update",
+ """---
+name: auto-zip-update
+description: Original
+---
+# Original
+""",
+ )
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("auto-zip-update/SKILL.md", """---
+name: auto-zip-update
+description: Auto-detected ZIP update
+---
+# Updated
+""")
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.update_skill_from_file(
+ zip_bytes, "auto-zip-update", file_type="auto"
+ )
+
+ assert result is not None
+ assert result["description"] == "Auto-detected ZIP update"
+
+ def test_update_skill_zip_skill_disappears_between_calls(self, mocker):
+ """If load_skill returns None on the second call inside _update_skill_from_zip, raise."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp.create_skill(
+ "disappear-update",
+ """---
+name: disappear-update
+description: Original
+---
+# Original
+""",
+ )
+
+ # First call (in update_skill_from_file) returns the skill,
+ # second call (in _update_skill_from_zip) returns None.
+ existing = manager.load_skill("disappear-update")
+ call_count = {"n": 0}
+
+ def fake_load_skill(name):
+ call_count["n"] += 1
+ if call_count["n"] >= 2:
+ return None
+ return existing
+
+ mocker.patch.object(manager, "load_skill", side_effect=fake_load_skill)
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("disappear-update/SKILL.md", """---
+name: disappear-update
+description: Disappear
+---
+# Updated
+""")
+
+ zip_bytes = zip_buffer.getvalue()
+ with pytest.raises(ValueError, match="Skill not found"):
+ manager.update_skill_from_file(
+ zip_bytes, "disappear-update", file_type="zip"
+ )
+
+
+class TestSkillManagerLoadSkillDirectoryWithSubdirs:
"""Additional tests for load_skill_directory."""
def test_load_directory_with_subdirs(self):
@@ -2601,7 +2676,9 @@ def test_get_file_tree_returns_empty_children_for_nonexistent_dir(self):
with TempSkillDir() as temp:
manager = SkillManager(base_skills_dir=temp.skills_dir)
- # Create skill entry but delete the actual directory
+ # Create skill entry then remove the actual directory so that
+ # load_skill still returns the cached data but os.path.exists
+ # is False inside get_skill_file_tree.
temp.create_skill(
"missing-dir-skill",
"""---
@@ -2611,17 +2688,56 @@ def test_get_file_tree_returns_empty_children_for_nonexistent_dir(self):
# Content
""",
)
+ # Delete only the inner content files but keep SKILL.md so load_skill
+ # still parses successfully. Then test the case where load_skill
+ # fails entirely (returns None).
+ shutil.rmtree(os.path.join(temp.skills_dir, "missing-dir-skill"))
- # Get file tree
result = manager.get_skill_file_tree("missing-dir-skill")
- # Should still return a tree structure (even if empty)
+ # When load_skill returns None because the dir is gone, return None.
+ assert result is None
+
+ def test_get_file_tree_returns_empty_tree_when_dir_gone(self, mocker):
+ """When load_skill succeeds but the dir vanishes between calls, return empty tree."""
+ with TempSkillDir() as temp:
+ temp.create_skill(
+ "phantom-dir-skill",
+ """---
+name: phantom-dir-skill
+description: Phantom dir
+---
+# Content
+""",
+ )
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ # Make os.path.exists return False on the second invocation to
+ # simulate the skill dir disappearing after load_skill.
+ real_exists = os.path.exists
+ call_count = {"n": 0}
+
+ def fake_exists(path):
+ call_count["n"] += 1
+ # Allow the first checks (used by load_skill) to pass,
+ # then fail the second call which is inside get_skill_file_tree.
+ if "phantom-dir-skill" in str(path) and call_count["n"] > 2:
+ return False
+ return real_exists(path)
+
+ mocker.patch("os.path.exists", side_effect=fake_exists)
+ # Force load_skill to use the cached file content directly.
+ loaded = manager.load_skill("phantom-dir-skill")
+ assert loaded is not None
+
+ result = manager.get_skill_file_tree("phantom-dir-skill")
assert result is not None
- assert result["name"] == "missing-dir-skill"
+ assert result["name"] == "phantom-dir-skill"
assert result["type"] == "directory"
-class TestSkillManagerCleanupSkillDirectory:
+class TestSkillManagerCleanupSkillDirectoryFileBranch:
"""Additional tests for cleanup_skill_directory."""
def test_cleanup_removes_file_instead_of_dir(self, mocker):
@@ -2644,7 +2760,7 @@ def test_cleanup_removes_file_instead_of_dir(self, mocker):
# Note: This test may be platform-dependent
-class TestSkillManagerRunSkillScript:
+class TestSkillManagerRunSkillScriptPathSafety:
"""Additional tests for run_skill_script."""
def test_run_unsupported_script_type_raises(self):
@@ -2668,6 +2784,146 @@ def test_run_unsupported_script_type_raises(self):
with pytest.raises(ValueError, match="Unsupported script type"):
manager.run_skill_script("unsupported-skill", "scripts/script.js")
+ def _create_run_script_skill(self, temp):
+ """Helper to build a skill that hosts a Python script under scripts/."""
+ temp.create_skill(
+ "rel-path-skill",
+ """---
+name: rel-path-skill
+description: Relative-path resolution test
+---
+# Content
+""",
+ subdirs={
+ "scripts": [{"name": "hello.py", "content": "print('hi')"}],
+ },
+ )
+
+ def test_run_skill_script_resolves_relative_to_skill_root(self, mocker):
+ """The script_path must resolve relative to the skill root, not CWD."""
+ with TempSkillDir() as temp:
+ self._create_run_script_skill(temp)
+
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "ok"
+ mock_result.stderr = ""
+ mock_sleep = mocker.patch("subprocess.run", return_value=mock_result)
+
+ cwd = os.path.join(temp.skills_dir, "rel-path-skill", "scripts")
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(cwd)
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ # Bare relative path from the SKILL.md body, no extension.
+ result = manager.run_skill_script("rel-path-skill", "scripts/hello")
+ finally:
+ os.chdir(old_cwd)
+
+ assert result == "ok"
+ # subprocess.run should have been invoked against the skill-root
+ # path, regardless of the caller's CWD.
+ called_script = mock_sleep.call_args[0][0][1]
+ assert called_script.endswith(os.path.join("rel-path-skill", "scripts", "hello.py"))
+ # Verify path is correctly resolved to skill root (not relative to CWD)
+ # The path should contain the full skill name with scripts subdirectory
+ skill_root_path = os.path.join(temp.skills_dir, "rel-path-skill")
+ assert os.path.abspath(called_script).startswith(os.path.abspath(skill_root_path))
+
+ def test_run_skill_script_accepts_dot_slash_prefix(self, mocker):
+ """Paths beginning with './' should resolve identically to bare paths."""
+ with TempSkillDir() as temp:
+ self._create_run_script_skill(temp)
+
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "out"
+ mock_result.stderr = ""
+ mock_sleep = mocker.patch("subprocess.run", return_value=mock_result)
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ result = manager.run_skill_script(
+ "rel-path-skill", "./scripts/hello.py"
+ )
+
+ assert result == "out"
+ called_script = mock_sleep.call_args[0][0][1]
+ assert called_script.endswith(os.path.join("rel-path-skill", "scripts", "hello.py"))
+
+ def test_run_skill_script_strips_surrounding_quotes(self, mocker):
+ """Paths pulled from backticks/fences may arrive wrapped in quotes."""
+ with TempSkillDir() as temp:
+ self._create_run_script_skill(temp)
+
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "out"
+ mock_result.stderr = ""
+ mock_sleep = mocker.patch("subprocess.run", return_value=mock_result)
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ result = manager.run_skill_script(
+ "rel-path-skill", '"scripts/hello.py"'
+ )
+
+ assert result == "out"
+ called_script = mock_sleep.call_args[0][0][1]
+ assert called_script.endswith(os.path.join("rel-path-skill", "scripts", "hello.py"))
+
+ def test_run_skill_script_falls_back_to_extension(self, mocker):
+ """When no extension is supplied, try .py then .sh fall-back."""
+ with TempSkillDir() as temp:
+ self._create_run_script_skill(temp)
+
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "shell-out"
+ mock_result.stderr = ""
+ # Also create a .sh script to verify ordering: .py wins when both exist.
+ sh_dir = os.path.join(temp.skills_dir, "rel-path-skill", "scripts")
+ with open(os.path.join(sh_dir, "hello.sh"), "w") as fh:
+ fh.write("echo hi")
+ mock_sleep = mocker.patch("subprocess.run", return_value=mock_result)
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ result = manager.run_skill_script("rel-path-skill", "scripts/hello")
+
+ assert result == "shell-out"
+ called_script = mock_sleep.call_args[0][0][1]
+ # .py takes precedence over .sh when both exist.
+ assert called_script.endswith(os.path.join("rel-path-skill", "scripts", "hello.py"))
+
+ def test_run_skill_script_missing_script_message_lists_available(self):
+ """When the script cannot be found, the error message lists available scripts."""
+ with TempSkillDir() as temp:
+ self._create_run_script_skill(temp)
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ with pytest.raises(SkillScriptNotFoundError) as excinfo:
+ manager.run_skill_script("rel-path-skill", "scripts/missing.py")
+
+ message = excinfo.value.message
+ # The error message must explain what the platform tried to do.
+ assert "scripts/missing.py" in message
+ assert "skill root" in message
+ assert "tried" in message
+ # And list the script we *did* create so the caller can correct itself.
+ assert "scripts/hello.py" in message
+
+ def test_run_skill_script_rejects_path_traversal(self):
+ """A path that escapes the skill root must be rejected, not silently run."""
+ with TempSkillDir() as temp:
+ self._create_run_script_skill(temp)
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ with pytest.raises(SkillScriptNotFoundError) as excinfo:
+ manager.run_skill_script(
+ "rel-path-skill",
+ "../../../etc/passwd",
+ )
+
+ assert "outside" in excinfo.value.message.lower() or "skill root" in excinfo.value.message.lower()
+
class TestSkillManagerListSkillsNonExistentDir:
"""Test list_skills when directory doesn't exist."""
@@ -2731,5 +2987,640 @@ def test_upload_zip_extracts_files_from_different_prefix(self):
assert os.path.exists(os.path.join(skill_dir, "other-prefix", "data.json"))
+class TestSkillManagerRunScriptNoneAndFallbacks:
+ """Cover run_skill_script edge cases: None path, fall-back miss, extensions."""
+
+ def test_run_skill_script_with_none_script_path(self, mocker):
+ """Passing script_path=None should produce a meaningful error message."""
+ with TempSkillDir() as temp:
+ temp.create_skill(
+ "none-path-skill",
+ """---
+name: none-path-skill
+description: None script_path test
+---
+# Content
+""",
+ subdirs={
+ "scripts": [{"name": "x.py", "content": "print('x')"}],
+ },
+ )
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ with pytest.raises(SkillScriptNotFoundError) as excinfo:
+ manager.run_skill_script("none-path-skill", None)
+
+ # The friendly error should reference the skill root and available scripts.
+ assert "none-path-skill" in excinfo.value.message
+ assert "scripts/x.py" in excinfo.value.message
+
+ def test_run_skill_script_no_extension_no_fallback(self):
+ """Without an extension and no .py/.sh fall-back, raise SkillScriptNotFoundError."""
+ with TempSkillDir() as temp:
+ temp.create_skill(
+ "no-fallback-skill",
+ """---
+name: no-fallback-skill
+description: No fallback test
+---
+# Content
+""",
+ subdirs={
+ "scripts": [{"name": "real.py", "content": "print('hi')"}],
+ },
+ )
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ with pytest.raises(SkillScriptNotFoundError) as excinfo:
+ # "scripts/totally_missing" has no extension and no fall-back.
+ manager.run_skill_script("no-fallback-skill", "scripts/totally_missing")
+
+ # The fall-back-missed branch message must appear.
+ assert "no .py/.sh fall-back matched" in excinfo.value.message
+
+ def test_run_skill_script_strips_backslash_leader(self, mocker):
+ """A leading backslash on the path is stripped before resolution."""
+ with TempSkillDir() as temp:
+ temp.create_skill(
+ "bs-leader-skill",
+ """---
+name: bs-leader-skill
+description: Backslash leader test
+---
+# Content
+""",
+ subdirs={
+ "scripts": [{"name": "run.py", "content": "print('hi')"}],
+ },
+ )
+
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "ok"
+ mock_result.stderr = ""
+ mock_run = mocker.patch("subprocess.run", return_value=mock_result)
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ result = manager.run_skill_script(
+ "bs-leader-skill", "\\scripts/run.py"
+ )
+
+ assert result == "ok"
+ called_script = mock_run.call_args[0][0][1]
+ assert called_script.endswith(os.path.join("bs-leader-skill", "scripts", "run.py"))
+
+
+class TestSkillManagerUpdateFromBytesIO:
+ """Cover update_skill_from_file when file_content is a BytesIO object."""
+
+ def test_update_skill_md_with_bytesio(self):
+ """Update SKILL.md via BytesIO."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp.create_skill(
+ "bytesio-update",
+ """---
+name: bytesio-update
+description: Original
+---
+# Original
+""",
+ )
+
+ new_content = io.BytesIO(b"""---
+name: bytesio-update
+description: BytesIO updated
+---
+# Updated
+""")
+
+ result = manager.update_skill_from_file(new_content, "bytesio-update")
+
+ assert result is not None
+ assert result["description"] == "BytesIO updated"
+
+ def test_update_skill_zip_with_bytesio(self):
+ """Update via ZIP passed as BytesIO."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp.create_skill(
+ "bytesio-zip-update",
+ """---
+name: bytesio-zip-update
+description: Original
+---
+# Original
+""",
+ )
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("bytesio-zip-update/SKILL.md", """---
+name: bytesio-zip-update
+description: BytesIO ZIP updated
+---
+# Updated
+""")
+ zf.writestr("bytesio-zip-update/scripts/added.py", "# Added\n")
+
+ zip_buffer.seek(0)
+ result = manager.update_skill_from_file(zip_buffer, "bytesio-zip-update")
+
+ assert result is not None
+ assert result["description"] == "BytesIO ZIP updated"
+
+
+class TestSkillManagerUploadZipEdgeCoverage:
+ """Cover ZIP upload internal branches (dir entries, deep nesting, fallback)."""
+
+ def test_upload_zip_skips_directory_entries(self):
+ """ZIP containing directory placeholders (ending with '/') must be skipped."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ # zipfile.ZipFile can write directory entries when an explicit name ends with '/'.
+ zi = zipfile.ZipInfo("dir-entries-skill/data/")
+ zi.external_attr = 0x10 # directory flag
+ zf.writestr(zi, "")
+ zf.writestr("dir-entries-skill/SKILL.md", """---
+name: dir-entries-skill
+description: dir entries
+---
+# Content
+""")
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.upload_skill_from_file(zip_bytes)
+
+ assert result is not None
+ assert result["name"] == "dir-entries-skill"
+
+ def test_upload_zip_with_deeply_nested_skill_md(self):
+ """A ZIP whose SKILL.md is nested 3+ levels deep exercises the elif branch."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ # 3+ parts triggers `elif len(parts) >= 2` branch on line 312.
+ zf.writestr("deep-skill/sub/SKILL.md", """---
+name: deep-skill
+description: Deeply nested
+---
+# Content
+""")
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.upload_skill_from_file(zip_bytes)
+
+ assert result is not None
+ assert result["name"] == "deep-skill"
+
+ def test_upload_zip_fallback_search_finds_skill_md(self):
+ """When SKILL.md isn't in the immediate top folder, the fallback search finds it."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ # SKILL.md nested 4 levels deep → not caught by primary loop.
+ zf.writestr("fallback-skill/inner/sub/SKILL.md", """---
+name: fallback-skill
+description: Fallback search
+---
+# Content
+""")
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.upload_skill_from_file(zip_bytes)
+
+ assert result is not None
+ assert result["name"] == "fallback-skill"
+
+
+class TestSkillManagerUpdateZipEdgeCoverage:
+ """Cover _update_skill_from_zip internal branches."""
+
+ def test_update_zip_with_deeply_nested_skill_md(self):
+ """Update ZIP with deeply nested SKILL.md exercises len(parts)>=2 branch."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp.create_skill(
+ "deep-update",
+ """---
+name: deep-update
+description: Original
+---
+# Original
+""",
+ )
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ # Deep nesting: parts = ["deep-update", "inner", "sub", "SKILL.md"], len >= 2.
+ zf.writestr("deep-update/inner/sub/SKILL.md", """---
+name: deep-update
+description: Deep updated
+---
+# Deep updated
+""")
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.update_skill_from_file(zip_bytes, "deep-update")
+
+ assert result is not None
+ assert result["description"] == "Deep updated"
+
+ def test_update_zip_skips_file_with_empty_relative_path(self):
+ """Update ZIP with a file at the skill root that becomes empty after stripping."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp.create_skill(
+ "empty-rel-update",
+ """---
+name: empty-rel-update
+description: Original
+---
+# Original
+""",
+ )
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("empty-rel-update/SKILL.md", """---
+name: empty-rel-update
+description: Updated
+---
+# Updated
+""")
+ # A file that becomes an empty relative_path after stripping the prefix.
+ zf.writestr("empty-rel-update/", "") # may be skipped before reaching that branch
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.update_skill_from_file(zip_bytes, "empty-rel-update")
+
+ assert result is not None
+
+
+class TestSkillManagerCleanupDirectoryPaths:
+ """Cover cleanup_skill_directory branches for both file and directory paths."""
+
+ def test_cleanup_removes_directory_branch(self):
+ """Cleanup successfully removes a temp directory matching the prefix pattern."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp_base = tempfile.gettempdir()
+ fake_temp = os.path.join(temp_base, f"skill_cleanup-skill_fakeid")
+ os.makedirs(fake_temp, exist_ok=True)
+ with open(os.path.join(fake_temp, "marker.txt"), "w") as f:
+ f.write("marker")
+
+ assert os.path.isdir(fake_temp)
+
+ manager.cleanup_skill_directory("cleanup-skill")
+
+ assert not os.path.exists(fake_temp)
+
+ def test_cleanup_removes_file_branch(self):
+ """Cleanup handles the case where the matching path is a regular file."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp_base = tempfile.gettempdir()
+ fake_temp_file = os.path.join(temp_base, f"skill_file-skill_fakeid")
+ with open(fake_temp_file, "w") as f:
+ f.write("data")
+
+ assert os.path.isfile(fake_temp_file)
+
+ manager.cleanup_skill_directory("file-skill")
+
+ assert not os.path.exists(fake_temp_file)
+
+ def test_cleanup_handles_os_remove_exception(self, mocker):
+ """If os.remove itself raises, cleanup logs a warning and continues."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp_base = tempfile.gettempdir()
+ # Use a unique suffix to avoid colliding with leftover artifacts.
+ unique_suffix = f"test{mocker.__hash__() % 100000}" if hasattr(mocker, "__hash__") else "testunique"
+ fake_path = os.path.join(temp_base, f"skill_osremove-skill_{unique_suffix}")
+ with open(fake_path, "w") as f:
+ f.write("now-a-file")
+ assert os.path.isfile(fake_path)
+ try:
+ mocker.patch("os.path.isdir", return_value=False)
+ mocker.patch("os.remove", side_effect=OSError("access denied"))
+
+ # Should not raise.
+ manager.cleanup_skill_directory("osremove-skill")
+ finally:
+ if os.path.exists(fake_path):
+ try:
+ os.remove(fake_path)
+ except OSError:
+ pass
+
+
+class TestSkillManagerListAvailableScripts:
+ """Cover _list_available_scripts internal branches."""
+
+ def test_list_available_scripts_when_skill_root_missing(self):
+ """When the skill root directory does not exist, return empty list."""
+ manager = SkillManager(base_skills_dir="/nonexistent/path")
+ result = manager._list_available_scripts("/nonexistent/skill")
+ assert result == []
+
+ def test_list_available_scripts_finds_files_at_root(self):
+ """Finds .py/.sh files even at the skill root (not just under scripts/)."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ skill_dir = os.path.join(temp.skills_dir, "root-scripts-skill")
+ os.makedirs(skill_dir)
+
+ with open(os.path.join(skill_dir, "SKILL.md"), "w") as f:
+ f.write("---\nname: root-scripts-skill\ndescription: Test\n---\n# Content\n")
+
+ # Script at root, not under scripts/.
+ with open(os.path.join(skill_dir, "root_script.py"), "w") as f:
+ f.write("# root\n")
+
+ result = manager._list_available_scripts(skill_dir)
+
+ assert "root_script.py" in result
+
+
+class TestSkillManagerFileTreeFoundDirectoryBranch:
+ """Cover get_skill_file_tree when directory node already exists in children."""
+
+ def test_get_file_tree_reuses_existing_directory(self):
+ """If two files share a directory prefix, that directory is reused (not duplicated)."""
+ with TempSkillDir() as temp:
+ skill_dir = os.path.join(temp.skills_dir, "reuse-skill")
+ os.makedirs(skill_dir)
+
+ with open(os.path.join(skill_dir, "SKILL.md"), "w") as f:
+ f.write("---\nname: reuse-skill\ndescription: Reuse test\n---\n# Content\n")
+
+ sub_dir = os.path.join(skill_dir, "common")
+ os.makedirs(sub_dir)
+ with open(os.path.join(sub_dir, "a.py"), "w") as f:
+ f.write("# a\n")
+ with open(os.path.join(sub_dir, "b.py"), "w") as f:
+ f.write("# b\n")
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+ result = manager.get_skill_file_tree("reuse-skill")
+
+ assert result is not None
+
+ def find_child(node, name):
+ for child in node.get("children", []):
+ if child["name"] == name:
+ return child
+ return None
+
+ common = find_child(result, "common")
+ assert common is not None
+ assert common["type"] == "directory"
+ file_names = [c["name"] for c in common["children"]]
+ assert "a.py" in file_names
+ assert "b.py" in file_names
+
+ # Verify the directory is not duplicated at top level.
+ top_dirs = [c for c in result["children"] if c["name"] == "common"]
+ assert len(top_dirs) == 1
+
+
+class TestSkillManagerUploadNoNameInFrontmatter:
+ """Cover _upload_skill_from_md branch where frontmatter has no name."""
+
+ def test_upload_md_empty_frontmatter_name_raises(self, mocker):
+ """When SKILL.md frontmatter parses without a name and no override is given, raise."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ # Bypass SkillLoader.parse to simulate a dict that lacks 'name'.
+ fake_data = {"description": "No name here"}
+
+ mocker.patch.object(
+ module_loader.SkillLoader,
+ "parse",
+ return_value=fake_data,
+ )
+
+ with pytest.raises(ValueError, match="Skill name is required"):
+ manager._upload_skill_from_md(b"any content")
+
+
+class TestSkillManagerUpdateZipRootSkillMd:
+ """Cover _update_skill_from_zip internal branch when SKILL.md is at ZIP root."""
+
+ def test_update_zip_with_skill_md_at_root(self):
+ """SKILL.md placed at ZIP root (parts=['SKILL.md'], len<2) exercises the branch."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp.create_skill(
+ "root-md-update",
+ """---
+name: root-md-update
+description: Original
+---
+# Original
+""",
+ )
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ # SKILL.md directly at root → parts=['SKILL.md'], len(parts)<2.
+ zf.writestr("SKILL.md", """---
+name: root-md-update
+description: Root updated
+---
+# Root updated
+""")
+ zf.writestr("extra/data.json", '{"k": "v"}')
+
+ zip_bytes = zip_buffer.getvalue()
+ result = manager.update_skill_from_file(zip_bytes, "root-md-update")
+
+ assert result is not None
+ assert result["description"] == "Root updated"
+
+
+class TestSkillManagerCleanupSkillDirectoryFileRemoval:
+ """Cover cleanup_skill_directory branch when path is a regular file."""
+
+ def test_cleanup_removes_file_path_branch(self):
+ """Cleanup hits the os.remove branch when the matched path is a file."""
+ with TempSkillDir() as temp:
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ temp_base = tempfile.gettempdir()
+ fake_path = os.path.join(temp_base, f"skill_filebranch-skill_abc123")
+ with open(fake_path, "w") as f:
+ f.write("data")
+ assert os.path.isfile(fake_path)
+
+ try:
+ manager.cleanup_skill_directory("filebranch-skill")
+ assert not os.path.exists(fake_path)
+ finally:
+ if os.path.exists(fake_path):
+ try:
+ os.remove(fake_path)
+ except OSError:
+ pass
+
+
+class TestSkillManagerLoadDirectoryMissingPath:
+ """Cover load_skill_directory branch when local_path no longer exists."""
+
+ def test_load_skill_directory_when_local_path_missing(self, mocker):
+ """If load_skill returns data but the on-disk directory is gone, still return skill."""
+ with TempSkillDir() as temp:
+ temp.create_skill(
+ "phantom-load-skill",
+ """---
+name: phantom-load-skill
+description: Phantom load
+---
+# Content
+""",
+ )
+
+ manager = SkillManager(base_skills_dir=temp.skills_dir)
+
+ real_exists = os.path.exists
+ call_count = {"n": 0}
+
+ def fake_exists(path):
+ call_count["n"] += 1
+ if "phantom-load-skill" in str(path) and call_count["n"] > 2:
+ return False
+ return real_exists(path)
+
+ mocker.patch("os.path.exists", side_effect=fake_exists)
+ loaded = manager.load_skill("phantom-load-skill")
+ assert loaded is not None
+
+ result = manager.load_skill_directory("phantom-load-skill")
+ # Should still return the skill dict (with directory set to a fresh tempdir).
+ assert result is not None
+ assert result["name"] == "phantom-load-skill"
+ # Cleanup the temp directory.
+ if os.path.isdir(result["directory"]):
+ shutil.rmtree(result["directory"])
+
+
+class TestSkillManagerAddToTreeTypeConflictBranch:
+ """Cover _add_to_tree internal branches: type conflict and dir reuse."""
+
+ def test_add_to_tree_directory_reuse_branch(self):
+ """Adding nested path into an existing directory node reuses that node."""
+ manager = SkillManager()
+ node = {
+ "name": "root",
+ "type": "directory",
+ "children": [
+ {"name": "dir1", "type": "directory", "children": []}
+ ],
+ }
+
+ # Recursing through parts=["dir1", "x.txt"], the first iteration finds dir1.
+ manager._add_to_tree(node, ["dir1", "x.txt"], is_directory=False)
+
+ assert len(node["children"]) == 1
+ assert node["children"][0]["name"] == "dir1"
+ assert node["children"][0]["children"][0]["name"] == "x.txt"
+
+ def test_add_to_tree_directory_creates_new_when_missing(self):
+ """When the directory prefix is missing, _add_to_tree creates it."""
+ manager = SkillManager()
+ node = {"name": "root", "type": "directory", "children": []}
+
+ manager._add_to_tree(node, ["newdir", "file.txt"], is_directory=False)
+
+ assert len(node["children"]) == 1
+ assert node["children"][0]["name"] == "newdir"
+ assert node["children"][0]["type"] == "directory"
+ assert node["children"][0]["children"][0]["name"] == "file.txt"
+
+ def test_add_to_tree_type_conflict_skip(self):
+ """When a name exists with conflicting type, the second add is skipped."""
+ manager = SkillManager()
+ node = {
+ "name": "root",
+ "type": "directory",
+ "children": [
+ {"name": "shared", "type": "directory", "children": []}
+ ],
+ }
+
+ # Adding "shared" as a file conflicts with the existing directory.
+ manager._add_to_tree(node, ["shared"], is_directory=False)
+
+ # No new file child should be appended.
+ assert len(node["children"]) == 1
+ assert node["children"][0]["name"] == "shared"
+ assert node["children"][0]["type"] == "directory"
+
+ def test_add_to_tree_no_match_in_existing_children(self):
+ """Adding a leaf that doesn't match any existing child exercises the loop-no-match branch."""
+ manager = SkillManager()
+ node = {
+ "name": "root",
+ "type": "directory",
+ "children": [
+ {"name": "alpha", "type": "file", "children": []},
+ {"name": "beta", "type": "file", "children": []},
+ ],
+ }
+
+ manager._add_to_tree(node, ["gamma"], is_directory=False)
+
+ names = [c["name"] for c in node["children"]]
+ assert "alpha" in names
+ assert "beta" in names
+ assert "gamma" in names
+ assert len(node["children"]) == 3
+
+ def test_add_to_tree_skip_non_directory_child_in_dir_lookup(self):
+ """When looking for a directory but the matching child is a file, skip it."""
+ manager = SkillManager()
+ # "data" exists as a file child, but the lookup wants a directory with that name.
+ node = {
+ "name": "root",
+ "type": "directory",
+ "children": [
+ {"name": "data", "type": "file", "children": []}
+ ],
+ }
+
+ # Adding parts=["data", "nested.txt"] should NOT reuse the file child.
+ # Instead it must create a new directory "data" alongside the existing file.
+ manager._add_to_tree(node, ["data", "nested.txt"], is_directory=False)
+
+ # Both the original file and the new directory should be present.
+ names = [c["name"] for c in node["children"]]
+ assert "data" in names
+ # The new directory is the one with type "directory".
+ dir_entries = [c for c in node["children"] if c["name"] == "data" and c["type"] == "directory"]
+ assert len(dir_entries) == 1
+ assert dir_entries[0]["children"][0]["name"] == "nested.txt"
+
+
if __name__ == "__main__":
pytest.main([__file__, "-v"])
diff --git a/test/sdk/vector_database/test_elasticsearch_core.py b/test/sdk/vector_database/test_elasticsearch_core.py
index e96b50ded..2cb56c32c 100644
--- a/test/sdk/vector_database/test_elasticsearch_core.py
+++ b/test/sdk/vector_database/test_elasticsearch_core.py
@@ -1606,236 +1606,6 @@ def test_hybrid_search_success(elasticsearch_core_instance):
mock_semantic.assert_called_once()
-def test_hybrid_search_with_missing_embeddings_generates_and_stores(elasticsearch_core_instance):
- """
- Test hybrid search when chunks exist in accurate results but not in semantic results
- (e.g., manually added chunks without embeddings).
- The system should generate embeddings, store them in ES, and re-execute semantic search.
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "test-model"
- mock_embedding_model.get_embeddings.return_value = [[0.1] * 1024]
-
- # Initial accurate search returns doc1, semantic search only returns doc2 (doc1 missing)
- # This simulates a chunk that was manually added without embedding
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- # First call: accurate returns doc1 (with content), semantic returns only doc2
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test doc 1 - needs embedding"},
- "index": "test_index"
- }
- ]
-
- # First semantic search doesn't find doc1 because it has no embedding
- mock_semantic.side_effect = [
- # First call returns doc2 only (doc1 missing because no embedding)
- [
- {
- "score": 0.8,
- "document": {"id": "doc2", "content": "Test doc 2"},
- "index": "test_index"
- }
- ],
- # Second call (after generating embedding) finds doc1
- [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test doc 1 - needs embedding"},
- "index": "test_index"
- },
- {
- "score": 0.8,
- "document": {"id": "doc2", "content": "Test doc 2"},
- "index": "test_index"
- }
- ]
- ]
-
- # Mock client.index for storing embedding
- mock_client.index.return_value = {"result": "created"}
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # Verify semantic_search was called twice (initial + after embedding)
- assert mock_semantic.call_count == 2
-
- # Verify client.index was called to store the embedding
- mock_client.index.assert_called_once()
- call_args = mock_client.index.call_args
- assert call_args.kwargs["id"] == "doc1"
- assert "embedding" in call_args.kwargs["document"]
-
- # Verify result includes doc1 with semantic_score
- assert len(result) >= 1
- doc_ids = [r["document"]["id"] for r in result]
- assert "doc1" in doc_ids
-
-
-def test_hybrid_search_no_missing_embeddings_no_retry(elasticsearch_core_instance):
- """
- Test hybrid search when all chunks have embeddings (no missing embeddings).
- Semantic search should only be called once.
- """
- mock_embedding_model = MagicMock()
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic:
-
- # Both searches return the same documents
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test doc 1"},
- "index": "test_index"
- }
- ]
-
- mock_semantic.return_value = [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test doc 1"},
- "index": "test_index"
- }
- ]
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # Semantic search should only be called once (no retry needed)
- assert mock_semantic.call_count == 1
- assert len(result) == 1
- assert result[0]["document"]["id"] == "doc1"
-
-
-def test_hybrid_search_handles_embedding_generation_failure(elasticsearch_core_instance):
- """
- Test hybrid search when embedding generation fails for chunks without embeddings.
- The search should still complete (gracefully handle failures).
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "test-model"
- mock_embedding_model.get_embeddings.return_value = [] # Empty embedding
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test doc 1"},
- "index": "test_index"
- }
- ]
-
- # Semantic only finds doc1 on second call
- mock_semantic.side_effect = [
- [], # First call: doc1 not found (no embedding)
- [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test doc 1"},
- "index": "test_index"
- }
- ]
- ]
-
- mock_client.index.return_value = {"result": "created"}
-
- # Should not raise exception even if embedding generation fails initially
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # Verify the search completed
- assert mock_semantic.call_count == 2
-
-
-def test_hybrid_search_empty_results(elasticsearch_core_instance):
- """Test hybrid search with empty results from both searches."""
- mock_embedding_model = MagicMock()
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic:
-
- mock_accurate.return_value = []
- mock_semantic.return_value = []
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- assert len(result) == 0
-
-
-# ----------------------------------------------------------------------------
-# Tests for statistics and monitoring
-# ----------------------------------------------------------------------------
-
-def test_get_documents_detail_success(elasticsearch_core_instance):
- """Test getting file list with details."""
- with patch.object(elasticsearch_core_instance.client, 'search') as mock_search:
- mock_search.return_value = {
- "aggregations": {
- "unique_sources": {
- "buckets": [
- {
- "doc_count": 3,
- "file_sample": {
- "hits": {
- "hits": [
- {
- "_source": {
- "path_or_url": "/path/to/file1.pdf",
- "filename": "file1.pdf",
- "file_size": 1024,
- "create_time": "2025-01-15T10:30:00"
- }
- }
- ]
- }
- }
- }
- ]
- }
- }
- }
-
- result = elasticsearch_core_instance.get_documents_detail(
- "test_index")
-
- assert len(result) == 1
- assert result[0]["path_or_url"] == "/path/to/file1.pdf"
- assert result[0]["filename"] == "file1.pdf"
- assert result[0]["file_size"] == 1024
- assert result[0]["chunk_count"] == 3
- mock_search.assert_called_once()
-
-
def test_get_indices_detail_success(elasticsearch_core_instance):
"""Test getting index statistics."""
with patch.object(elasticsearch_core_instance.client.indices, 'stats') as mock_stats, \
@@ -2110,62 +1880,6 @@ def test_hybrid_search_skips_semantic_result_with_missing_fields(elasticsearch_c
assert any("Missing required field in semantic result" in m for m in caplog.messages)
-def test_hybrid_search_stores_embedding_failure_continues_processing(elasticsearch_core_instance, caplog):
- """
- Test hybrid_search continues processing when storing embedding fails (lines 1101-1104).
- When client.index raises exception during embedding storage, it should log warning
- and continue to next document instead of failing the entire search.
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "test-model"
- mock_embedding_model.get_embeddings.return_value = [[0.1] * 1024]
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- # Accurate returns doc1 (will need embedding storage)
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test doc 1 - needs embedding"},
- "index": "test_index"
- }
- ]
-
- # Semantic returns empty first (doc1 needs embedding), then returns doc1 after storage
- mock_semantic.side_effect = [
- [], # First call: doc1 not found (no embedding)
- [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test doc 1 - needs embedding"},
- "index": "test_index"
- }
- ]
- ]
-
- # First index call fails, second succeeds
- mock_client.index.side_effect = [
- Exception("Index storage failed"), # This should trigger the warning and continue
- {"result": "created"}
- ]
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # Should complete successfully despite storage failure
- assert mock_semantic.call_count == 2
- # Warning should be logged about failed embedding storage
- assert any("Failed to store embedding for chunk doc1" in m for m in caplog.messages)
- assert len(result) >= 1
-
-
def test_hybrid_search_adds_new_documents_from_semantic_results(elasticsearch_core_instance):
"""
Test hybrid_search adds documents from semantic_results that don't exist in accurate results (lines 1124-1133).
@@ -2219,186 +1933,6 @@ def test_hybrid_search_adds_new_documents_from_semantic_results(elasticsearch_co
assert doc2_result["scores"]["accurate"] == 0
-def test_hybrid_search_missing_embedding_stores_with_model_name(elasticsearch_core_instance):
- """
- Test that hybrid_search correctly stores embedding_model_name when storing embeddings.
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "jina-embeddings-v2"
- mock_embedding_model.get_embeddings.return_value = [[0.1] * 1024]
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test doc 1"},
- "index": "test_index"
- }
- ]
-
- mock_semantic.side_effect = [
- [], # First call: doc1 not found
- [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test doc 1"},
- "index": "test_index"
- }
- ]
- ]
-
- mock_client.index.return_value = {"result": "created"}
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # Verify client.index was called with correct embedding_model_name
- mock_client.index.assert_called_once()
- call_args = mock_client.index.call_args
- assert call_args.kwargs["document"]["embedding_model_name"] == "jina-embeddings-v2"
-
-
-def test_hybrid_search_empty_content_skips_embedding_generation(elasticsearch_core_instance, caplog):
- """
- Test hybrid_search skips embedding generation for chunks with empty content.
- When chunk_content is empty, the embedding generation should be skipped.
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "test-model"
- mock_embedding_model.get_embeddings.return_value = [[0.1] * 1024]
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- # Accurate returns a doc with empty content
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": ""}, # Empty content
- "index": "test_index"
- }
- ]
-
- mock_semantic.return_value = [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": ""},
- "index": "test_index"
- }
- ]
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # client.index should NOT be called because content is empty
- mock_client.index.assert_not_called()
- # Should still return result
- assert len(result) == 1
-
-
-def test_hybrid_search_empty_index_name_skips_embedding_generation(elasticsearch_core_instance, caplog):
- """
- Test hybrid_search skips embedding generation when index_name is empty.
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "test-model"
- mock_embedding_model.get_embeddings.return_value = [[0.1] * 1024]
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- # Accurate returns a doc with empty index_name
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test content"},
- "index": "" # Empty index name
- }
- ]
-
- mock_semantic.return_value = [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test content"},
- "index": ""
- }
- ]
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # client.index should NOT be called because index_name is empty
- mock_client.index.assert_not_called()
- assert len(result) == 1
-
-
-def test_hybrid_search_empty_embedding_skips_storage(elasticsearch_core_instance, caplog):
- """
- Test hybrid_search skips embedding storage when embedding generation returns empty.
- """
- mock_embedding_model = MagicMock()
- mock_embedding_model.embedding_model_name = "test-model"
- mock_embedding_model.get_embeddings.return_value = [] # Empty embedding
-
- with patch.object(elasticsearch_core_instance, 'accurate_search') as mock_accurate, \
- patch.object(elasticsearch_core_instance, 'semantic_search') as mock_semantic, \
- patch.object(elasticsearch_core_instance, 'client') as mock_client:
-
- mock_accurate.return_value = [
- {
- "score": 10.0,
- "document": {"id": "doc1", "content": "Test content"},
- "index": "test_index"
- }
- ]
-
- mock_semantic.side_effect = [
- [], # First call: doc1 not found
- [
- {
- "score": 0.9,
- "document": {"id": "doc1", "content": "Test content"},
- "index": "test_index"
- }
- ]
- ]
-
- mock_client.index.return_value = {"result": "created"}
-
- result = elasticsearch_core_instance.hybrid_search(
- ["test_index"],
- "test query",
- mock_embedding_model,
- top_k=5,
- weight_accurate=0.3
- )
-
- # client.index should NOT be called because embedding is empty
- mock_client.index.assert_not_called()
- # Should still complete search
- assert mock_semantic.call_count == 2
-
-
def test_create_index_request_error_already_exists(elasticsearch_core_instance):
from elasticsearch import exceptions as es_exceptions
with patch.object(elasticsearch_core_instance, "client") as mock_client, \
From 80a5d1cc3637237c0e64af676c78998ff2ef4cf9 Mon Sep 17 00:00:00 2001
From: hhhhsc701 <56435672+hhhhsc701@users.noreply.github.com>
Date: Thu, 9 Jul 2026 10:02:21 +0800
Subject: [PATCH 12/26] Add image registry prefix support and optimize offline
deployment (#3388)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add image registry prefix support to offline deployment
* Add image registry prefix support to offline deployment
* 优化构建包
* Support source-suffixed offline packages and configurable init image
* k8s部分参数落盘
* clean code
---------
Co-authored-by: hhhhsc
---
.github/workflows/build-offline-package.yml | 52 ++--
README.md | 4 +-
README_CN.md | 4 +-
deploy.sh | 104 +++++++-
deploy/common/common.sh | 191 +++++++++++++--
.../compose/docker-compose-monitoring.yml | 22 +-
deploy/docker/deploy.sh | 2 +
deploy/k8s/deploy.sh | 6 +
.../templates/deployment.yaml | 4 +-
.../charts/nexent-supabase-auth/values.yaml | 5 +
.../templates/deployment.yaml | 2 +
deploy/offline/build_offline_package.sh | 104 +++++---
deploy/offline/load-images.sh | 18 ++
deploy/offline/push-images.sh | 223 ++++++++++++++++++
deploy/tests/test_build_offline_package.sh | 96 +++++++-
deploy/tests/test_common.sh | 46 ++++
doc/docs/en/deployment/docker-build.md | 2 +
doc/docs/en/quick-start/installation.md | 10 +-
.../en/quick-start/kubernetes-installation.md | 10 +-
doc/docs/zh/deployment/docker-build.md | 2 +
doc/docs/zh/quick-start/installation.md | 10 +-
.../zh/quick-start/kubernetes-installation.md | 10 +-
22 files changed, 812 insertions(+), 115 deletions(-)
create mode 100755 deploy/offline/load-images.sh
create mode 100755 deploy/offline/push-images.sh
diff --git a/.github/workflows/build-offline-package.yml b/.github/workflows/build-offline-package.yml
index ae67ed4ca..4c72cecc4 100644
--- a/.github/workflows/build-offline-package.yml
+++ b/.github/workflows/build-offline-package.yml
@@ -6,15 +6,7 @@ on:
version:
description: 'Image version tag, e.g. v2.2.0 or latest'
required: false
- default: ''
- platform:
- description: 'Target platform'
- required: false
- default: 'amd64'
- type: choice
- options:
- - amd64
- - arm64
+ default: 'latest'
image_source:
description: 'Image source'
required: false
@@ -23,15 +15,6 @@ on:
options:
- general
- mainland
- target:
- description: 'Package target'
- required: false
- default: 'all'
- type: choice
- options:
- - docker
- - k8s
- - all
include_source:
description: 'Include source code in the package'
required: false
@@ -41,6 +24,12 @@ on:
jobs:
build-offline-package:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ platform:
+ - amd64
+ - arm64
steps:
- name: Free disk space
@@ -63,7 +52,7 @@ jobs:
- name: Set version and platform variables
id: set-vars
run: |
- PLATFORM="${{ inputs.platform }}"
+ PLATFORM="${{ matrix.platform }}"
REF_TYPE="${{ github.ref_type }}"
REF_NAME="${{ github.ref_name }}"
@@ -81,9 +70,14 @@ jobs:
VERSION="latest"
fi
+ SOURCE_SUFFIX=""
+ if [ "${{ inputs.include_source }}" = "true" ]; then
+ SOURCE_SUFFIX="-with-source"
+ fi
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "platform=$PLATFORM" >> $GITHUB_OUTPUT
- echo "package-name=nexent-offline-${{ inputs.target }}-${PLATFORM}-${VERSION}" >> $GITHUB_OUTPUT
+ echo "package-name=nexent-${VERSION}-${PLATFORM}${SOURCE_SUFFIX}" >> $GITHUB_OUTPUT
- name: Set deployment components
id: set-components
@@ -104,24 +98,25 @@ jobs:
--include-source "${{ inputs.include_source }}" \
--image-source "${{ inputs.image_source }}" \
--components "${{ steps.set-components.outputs.components }}" \
- --target "${{ inputs.target }}" \
- --compress true
-
-
+ --target all \
+ --compress false
- - name: Show zip package
+ - name: Show offline package
run: |
PACKAGE_NAME="${{ steps.set-vars.outputs.package-name }}"
- echo "Package created by build script: ${PACKAGE_NAME}.zip"
+ echo "Package directory created by build script: ./offline-output"
+ echo "GitHub Actions will publish the final downloadable artifact as ${PACKAGE_NAME}.zip"
- ls -lh "${PACKAGE_NAME}.zip"
+ du -sh ./offline-output
+ find ./offline-output -maxdepth 2 -type f | sort | head -50
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.set-vars.outputs.package-name }}
- path: ${{ steps.set-vars.outputs.package-name }}.zip
+ path: ./offline-output
+ if-no-files-found: error
retention-days: 30
- name: Summary
@@ -133,6 +128,7 @@ jobs:
echo "Version: ${{ steps.set-vars.outputs.version }}"
echo "Platform: ${{ steps.set-vars.outputs.platform }}"
echo "Package: ${{ steps.set-vars.outputs.package-name }}.zip"
+ echo "Note: the downloaded artifact zip contains the offline package contents directly."
echo "Target: ${{ inputs.target }}"
echo "Components: ${{ steps.set-components.outputs.components }}"
echo "Image source: ${{ inputs.image_source }}"
diff --git a/README.md b/README.md
index 794e552e3..7f2849544 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ Docker and Kubernetes both use `deploy/env/.env` as the runtime configuration fi
Docker uninstall is handled by `bash uninstall.sh docker`. It can preserve or delete data volumes: run it interactively, pass `--delete-volumes true|false`, or use `bash uninstall.sh docker delete-all` to remove containers and persistent data.
-Offline image packages can be built with `bash build.sh --package --target docker --compress true` or `bash deploy/offline/build_offline_package.sh --target docker --compress true`. The package includes image tar files, `load-images.sh`, root deploy/uninstall entrypoints, deployment scripts, SQL files, `manifest.yaml`, and `checksums.txt`. Package deploys use saved `deploy.options` or built-in defaults without opening the TUI; add `--config` to configure interactively. Deploy with `bash deploy.sh --load-images docker ...` on the target host.
+Offline image packages can be built with `bash build.sh --package --target docker --compress true` or `bash deploy/offline/build_offline_package.sh --target docker --compress true`. The package includes image tar files, `load-images.sh`, `push-images.sh`, root deploy/uninstall entrypoints, deployment scripts, SQL files, `manifest.yaml`, and `checksums.txt`. Package deploys use saved `deploy.options` or built-in defaults without opening the TUI; add `--config` to configure interactively. Deploy with `bash deploy.sh --load-images docker ...` on the target host, or use `bash deploy.sh --push-images --image-registry-prefix registry.example.com/nexent docker ...` to push loaded images to an internal registry and deploy with that image prefix. When `--push-images` is used without a prefix, `deploy.sh` asks for it before `push-images.sh` prompts for the registry username and password.
For detailed deployment instructions, see [Docker Installation](https://modelengine-group.github.io/nexent/en/quick-start/installation.html).
@@ -74,7 +74,7 @@ The native Kubernetes implementation is `bash deploy/k8s/deploy.sh`. It reads th
Kubernetes uninstall is handled by `bash uninstall.sh k8s`. It removes the Helm release first, then can optionally delete the namespace and local PV data. Use `--delete-namespace true|false`, `--delete-local-data true|false`, or `bash uninstall.sh k8s delete-all`; pass `--keep-local-data` with `delete-all` to preserve local volume contents.
-Kubernetes offline packages use the same builder with `--target k8s` or `--target all`. Run `load-images.sh` on every cluster node that needs the images, or push the loaded images to an internal registry before deploying with the same version and image-source options used during packaging.
+Kubernetes offline packages use the same builder with `--target k8s` or `--target all`. Run `load-images.sh` on every cluster node that needs the images, or use `--push-images --image-registry-prefix registry.example.com/nexent` to push the images to an internal registry before deploying with the same version, image source, and image registry prefix.
For detailed deployment instructions, see [Kubernetes Installation](https://modelengine-group.github.io/nexent/en/quick-start/kubernetes-installation.html).
diff --git a/README_CN.md b/README_CN.md
index 8d4488201..24402f9b7 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -56,7 +56,7 @@ Docker 与 Kubernetes 统一使用 `deploy/env/.env` 作为运行配置文件;
Docker 卸载入口为 `bash uninstall.sh docker`,默认交互确认是否删除持久化数据;也可以通过 `--delete-volumes true|false` 控制,或使用 `bash uninstall.sh docker delete-all` 同时删除容器和持久化数据。
-离线镜像包可通过 `bash build.sh --package --target docker --compress true` 或 `bash deploy/offline/build_offline_package.sh --target docker --compress true` 构建。包内包含镜像 tar、`load-images.sh`、根目录部署/卸载入口、部署脚本、SQL 文件、`manifest.yaml` 和 `checksums.txt`。包内部署会复用已保存的 `deploy.options` 或内置默认值,默认不进入 TUI;添加 `--config` 可交互配置。在目标机器上使用 `bash deploy.sh --load-images docker ...` 加载镜像并部署。
+离线镜像包可通过 `bash build.sh --package --target docker --compress true` 或 `bash deploy/offline/build_offline_package.sh --target docker --compress true` 构建。包内包含镜像 tar、`load-images.sh`、`push-images.sh`、根目录部署/卸载入口、部署脚本、SQL 文件、`manifest.yaml` 和 `checksums.txt`。包内部署会复用已保存的 `deploy.options` 或内置默认值,默认不进入 TUI;添加 `--config` 可交互配置。在目标机器上使用 `bash deploy.sh --load-images docker ...` 加载镜像并部署,或使用 `bash deploy.sh --push-images --image-registry-prefix registry.example.com/nexent docker ...` 推送到内部仓库并使用该镜像前缀部署。启用 `--push-images` 且未传前缀时,`deploy.sh` 会先询问镜像仓库前缀,随后 `push-images.sh` 询问仓库账号和密码。
详细部署指南请参考 [Docker 安装部署](https://modelengine-group.github.io/nexent/zh/quick-start/installation.html)。
@@ -74,7 +74,7 @@ Kubernetes 真实实现为 `bash deploy/k8s/deploy.sh`。它会读取同一个`d
根目录卸载入口为 `bash uninstall.sh docker ...` 或 `bash uninstall.sh k8s ...`,具体实现仍分别在 `deploy/docker/uninstall.sh` 和 `deploy/k8s/uninstall.sh`。
-Kubernetes 离线包使用同一个构建脚本,传入 `--target k8s` 或 `--target all`。部署前需要在每个需要运行 Pod 的节点上执行 `load-images.sh`,或将镜像推送到集群可访问的内部镜像仓库,再使用与打包时一致的版本和镜像源参数部署。
+Kubernetes 离线包使用同一个构建脚本,传入 `--target k8s` 或 `--target all`。部署前需要在每个需要运行 Pod 的节点上执行 `load-images.sh`,或使用 `--push-images --image-registry-prefix registry.example.com/nexent` 将镜像推送到集群可访问的内部镜像仓库,再使用与打包时一致的版本、镜像源和镜像仓库前缀部署。
详细部署指南请参考 [Kubernetes 安装部署](https://modelengine-group.github.io/nexent/zh/quick-start/kubernetes-installation.html)。
diff --git a/deploy.sh b/deploy.sh
index aa921b714..aad8dae84 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -16,8 +16,8 @@ usage() {
if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
cat <<'USAGE'
用法:
- bash deploy.sh [--load-images] [--config|--defaults] docker [Docker 部署选项]
- bash deploy.sh [--load-images] [--config|--defaults] k8s [K8s 部署选项]
+ bash deploy.sh [--load-images] [--push-images] [--image-registry-prefix PREFIX] [--config|--defaults] docker [Docker 部署选项]
+ bash deploy.sh [--load-images] [--push-images] [--image-registry-prefix PREFIX] [--config|--defaults] k8s [K8s 部署选项]
USAGE
if [ "$DEPLOY_WRAPPER_DEFAULT_CONFIG_MODE" = "defaults" ]; then
@@ -38,6 +38,10 @@ USAGE
选项:
--load-images 部署前从 ./images 加载 Docker 镜像 tar 文件。
默认关闭。
+ --push-images 部署前调用 push-images.sh 推送镜像。
+ --image-registry-prefix PREFIX
+ 镜像仓库前缀,例如 registry.example.com/nexent。
+ 使用 --push-images 且未传入时会交互询问。
--config 进入交互式部署配置界面。
--defaults 复用保存配置或内置默认值,跳过交互界面。
USAGE
@@ -46,8 +50,8 @@ USAGE
cat <<'USAGE'
Usage:
- bash deploy.sh [--load-images] [--config|--defaults] docker [docker deploy options]
- bash deploy.sh [--load-images] [--config|--defaults] k8s [k8s deploy options]
+ bash deploy.sh [--load-images] [--push-images] [--image-registry-prefix PREFIX] [--config|--defaults] docker [docker deploy options]
+ bash deploy.sh [--load-images] [--push-images] [--image-registry-prefix PREFIX] [--config|--defaults] k8s [k8s deploy options]
USAGE
if [ "$DEPLOY_WRAPPER_DEFAULT_CONFIG_MODE" = "defaults" ]; then
@@ -68,6 +72,10 @@ USAGE
Options:
--load-images Load Docker image tar files from ./images before deploying.
Defaults to off.
+ --push-images Run push-images.sh before deploying.
+ --image-registry-prefix PREFIX
+ Image registry prefix, e.g. registry.example.com/nexent.
+ Prompts when --push-images is used and no prefix is provided.
--config Open the interactive deployment configuration.
--defaults Use saved configuration or built-in defaults and skip TUI.
USAGE
@@ -79,6 +87,8 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ] || [ $# -eq 0 ]; then
fi
LOAD_IMAGES="false"
+PUSH_IMAGES="false"
+IMAGE_REGISTRY_PREFIX="${IMAGE_REGISTRY_PREFIX:-}"
DEPLOY_CONFIG_MODE="$DEPLOY_WRAPPER_DEFAULT_CONFIG_MODE"
FORWARD_ARGS=()
@@ -88,6 +98,22 @@ while [ $# -gt 0 ]; do
LOAD_IMAGES="true"
shift
;;
+ --push-images)
+ PUSH_IMAGES="true"
+ shift
+ ;;
+ --image-registry-prefix|--registry-prefix|--image-registry)
+ if [ $# -lt 2 ]; then
+ if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
+ echo "错误:$1 需要一个值" >&2
+ else
+ echo "Error: $1 requires a value" >&2
+ fi
+ exit 1
+ fi
+ IMAGE_REGISTRY_PREFIX="$2"
+ shift 2
+ ;;
--config)
DEPLOY_CONFIG_MODE="tui"
shift
@@ -103,12 +129,58 @@ while [ $# -gt 0 ]; do
esac
done
+normalize_image_registry_prefix() {
+ if declare -F deployment_normalize_image_registry_prefix_value >/dev/null 2>&1; then
+ deployment_normalize_image_registry_prefix_value "$1"
+ return 0
+ fi
+
+ local prefix="$1"
+ prefix="${prefix#"${prefix%%[![:space:]]*}"}"
+ prefix="${prefix%"${prefix##*[![:space:]]}"}"
+ prefix="${prefix#http://}"
+ prefix="${prefix#https://}"
+ while [[ "$prefix" == */ ]]; do
+ prefix="${prefix%/}"
+ done
+ printf '%s' "$prefix"
+}
+
+require_image_registry_prefix() {
+ if [ -z "$IMAGE_REGISTRY_PREFIX" ]; then
+ if [ -t 0 ]; then
+ if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
+ read -r -p "请输入镜像仓库前缀(例如 registry.example.com/nexent): " IMAGE_REGISTRY_PREFIX
+ else
+ read -r -p "Enter image registry prefix (e.g. registry.example.com/nexent): " IMAGE_REGISTRY_PREFIX
+ fi
+ else
+ if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
+ echo "错误:--push-images 需要 --image-registry-prefix,或设置 IMAGE_REGISTRY_PREFIX。" >&2
+ else
+ echo "Error: --push-images requires --image-registry-prefix or IMAGE_REGISTRY_PREFIX." >&2
+ fi
+ exit 1
+ fi
+ fi
+
+ IMAGE_REGISTRY_PREFIX="$(normalize_image_registry_prefix "$IMAGE_REGISTRY_PREFIX")"
+ if [ -z "$IMAGE_REGISTRY_PREFIX" ]; then
+ if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
+ echo "错误:镜像仓库前缀不能为空。" >&2
+ else
+ echo "Error: image registry prefix cannot be empty." >&2
+ fi
+ exit 1
+ fi
+}
+
if [ "${#FORWARD_ARGS[@]}" -eq 0 ]; then
usage
exit 0
fi
-if [ "$LOAD_IMAGES" = "true" ]; then
+if [ "$LOAD_IMAGES" = "true" ] && [ "$PUSH_IMAGES" != "true" ]; then
LOAD_SCRIPT="$SCRIPT_DIR/load-images.sh"
if [ ! -f "$LOAD_SCRIPT" ]; then
if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
@@ -121,6 +193,28 @@ if [ "$LOAD_IMAGES" = "true" ]; then
bash "$LOAD_SCRIPT"
fi
+if [ "$PUSH_IMAGES" = "true" ]; then
+ PUSH_SCRIPT="$SCRIPT_DIR/push-images.sh"
+ if [ ! -f "$PUSH_SCRIPT" ]; then
+ if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then
+ echo "错误:--push-images 需要 $PUSH_SCRIPT" >&2
+ else
+ echo "Error: --push-images requires $PUSH_SCRIPT" >&2
+ fi
+ exit 1
+ fi
+
+ require_image_registry_prefix
+ bash "$PUSH_SCRIPT" --image-registry-prefix "$IMAGE_REGISTRY_PREFIX" --load-images
+fi
+
+if [ -n "$IMAGE_REGISTRY_PREFIX" ]; then
+ IMAGE_REGISTRY_PREFIX="$(normalize_image_registry_prefix "$IMAGE_REGISTRY_PREFIX")"
+ if [ -n "$IMAGE_REGISTRY_PREFIX" ]; then
+ FORWARD_ARGS+=("--image-registry-prefix" "$IMAGE_REGISTRY_PREFIX")
+ fi
+fi
+
if [ -n "$DEPLOY_CONFIG_MODE" ]; then
NEXENT_DEPLOY_CONFIG_MODE="$DEPLOY_CONFIG_MODE" exec bash "$SCRIPT_DIR/deploy/deploy.sh" "${FORWARD_ARGS[@]}"
fi
diff --git a/deploy/common/common.sh b/deploy/common/common.sh
index 096d2074b..b9649e77a 100755
--- a/deploy/common/common.sh
+++ b/deploy/common/common.sh
@@ -9,12 +9,14 @@ DEPLOYMENT_COMPONENTS_DEFAULT="infrastructure,application,data-process,supabase"
DEPLOYMENT_PORT_POLICY_DEFAULT="development"
DEPLOYMENT_IMAGE_SOURCE_DEFAULT="general"
DEPLOYMENT_REGISTRY_PROFILE_DEFAULT="general"
+DEPLOYMENT_IMAGE_REGISTRY_PREFIX_DEFAULT=""
DEPLOYMENT_MONITORING_PROVIDER_DEFAULT="otlp"
DEPLOYMENT_COMPONENTS=""
DEPLOYMENT_PORT_POLICY=""
DEPLOYMENT_IMAGE_SOURCE=""
DEPLOYMENT_REGISTRY_PROFILE=""
+DEPLOYMENT_IMAGE_REGISTRY_PREFIX=""
DEPLOYMENT_APP_VERSION=""
DEPLOYMENT_MONITORING_PROVIDER=""
DEPLOYMENT_USE_LOCAL_CONFIG="false"
@@ -113,6 +115,7 @@ deployment_i18n_format() {
validation.unsupported_port_policy) printf '%s' '不支持的端口策略:%s。可用值:development 或 production。' ;;
validation.unsupported_image_source) printf '%s' '不支持的镜像源:%s。可用值:general、mainland 或 local-latest。' ;;
validation.unsupported_registry_profile) printf '%s' '不支持的 registry profile:%s' ;;
+ validation.unsupported_image_registry_prefix) printf '%s' '不支持的镜像仓库前缀:%s。请使用 registry.example.com/project 格式,不要包含空格。' ;;
validation.unsupported_monitoring_provider) printf '%s' '不支持的监控 provider:%s' ;;
tui.cancelled) printf '已取消部署配置。' ;;
tui.components.title) printf '选择部署组件' ;;
@@ -149,13 +152,14 @@ deployment_i18n_format() {
image_build.detail.docs) printf 'VitePress 文档站点' ;;
local_config.found) printf '%s' '发现已有部署配置:%s' ;;
local_config.choose) printf '请选择如何处理已保存的部署选项:' ;;
- local_config.use) printf ' 1) 使用本地配置 - 跳过菜单,复用已保存的组件、端口策略、镜像源和监控 provider。' ;;
+ local_config.use) printf ' 1) 使用本地配置 - 跳过菜单,复用已保存的组件、端口策略、镜像源、镜像仓库前缀和监控 provider。' ;;
local_config.reconfigure) printf ' 2) 重新配置 - 将已保存的值作为默认值,并显示菜单供修改。' ;;
local_config.reconfigure_hint) printf ' 启用/禁用监控、切换 provider 或调整部署范围时请选择此项。' ;;
prompt.choose_1_2) printf '请选择 [1/2](默认:1):' ;;
summary.components) printf '%s' '部署组件:%s' ;;
summary.port_policy) printf '%s' '端口策略:%s' ;;
summary.image_source) printf '%s' '镜像源:%s' ;;
+ summary.image_registry_prefix) printf '%s' '镜像仓库前缀:%s' ;;
summary.monitoring_provider) printf '%s' '监控 provider:%s' ;;
summary.docker_services) printf '%s' 'Docker 服务:%s' ;;
summary.docker_ports) printf '%s' 'Docker 暴露端口:%s' ;;
@@ -173,6 +177,7 @@ deployment_i18n_format() {
validation.unsupported_port_policy) printf '%s' 'Unsupported port policy: %s. Use development or production.' ;;
validation.unsupported_image_source) printf '%s' 'Unsupported image source: %s. Use general, mainland, or local-latest.' ;;
validation.unsupported_registry_profile) printf '%s' 'Unsupported registry profile: %s' ;;
+ validation.unsupported_image_registry_prefix) printf '%s' 'Unsupported image registry prefix: %s. Use registry.example.com/project format without spaces.' ;;
validation.unsupported_monitoring_provider) printf '%s' 'Unsupported monitoring provider: %s' ;;
tui.cancelled) printf 'Deployment configuration cancelled.' ;;
tui.components.title) printf 'Select deployment components' ;;
@@ -209,13 +214,14 @@ deployment_i18n_format() {
image_build.detail.docs) printf 'VitePress documentation site' ;;
local_config.found) printf '%s' 'Existing deployment config found: %s' ;;
local_config.choose) printf 'Choose how to handle saved deployment options:' ;;
- local_config.use) printf ' 1) Use local config - skip the menus and reuse the saved components, port policy, image source, and monitoring provider.' ;;
+ local_config.use) printf ' 1) Use local config - skip the menus and reuse the saved components, port policy, image source, image registry prefix, and monitoring provider.' ;;
local_config.reconfigure) printf ' 2) Reconfigure - load the saved values as defaults, then show the menus so you can change them.' ;;
local_config.reconfigure_hint) printf ' Choose this option when enabling or disabling monitoring, switching providers, or changing deployment scope.' ;;
prompt.choose_1_2) printf 'Choose [1/2] (default: 1): ' ;;
summary.components) printf '%s' 'Deployment components: %s' ;;
summary.port_policy) printf '%s' 'Port policy: %s' ;;
summary.image_source) printf '%s' 'Image source: %s' ;;
+ summary.image_registry_prefix) printf '%s' 'Image registry prefix: %s' ;;
summary.monitoring_provider) printf '%s' 'Monitoring provider: %s' ;;
summary.docker_services) printf '%s' 'Docker services: %s' ;;
summary.docker_ports) printf '%s' 'Docker published ports: %s' ;;
@@ -579,6 +585,7 @@ deployment_prepare_monitoring_env() {
local legacy_file
local telemetry_enabled
local dashboard_url
+ local existing_dashboard_url
local provider
local collector_config_file
local otlp_endpoint
@@ -607,8 +614,13 @@ deployment_prepare_monitoring_env() {
deployment_update_monitoring_env_var "MONITORING_PROVIDER" "$provider"
deployment_source_env_file "$env_file"
- dashboard_url="$(deployment_monitoring_dashboard_url "$target")"
- deployment_update_monitoring_env_var "MONITORING_DASHBOARD_URL" "$dashboard_url"
+ existing_dashboard_url="$(deployment_get_env_var_file "$env_file" "MONITORING_DASHBOARD_URL" 2>/dev/null || true)"
+ if [ "$telemetry_enabled" != "true" ]; then
+ deployment_update_monitoring_env_var "MONITORING_DASHBOARD_URL" ""
+ elif [ -z "$existing_dashboard_url" ]; then
+ dashboard_url="$(deployment_monitoring_dashboard_url "$target")"
+ deployment_update_monitoring_env_var "MONITORING_DASHBOARD_URL" "$dashboard_url"
+ fi
case "$target" in
k8s|helm)
@@ -692,6 +704,7 @@ deployment_init_defaults() {
DEPLOYMENT_PORT_POLICY="$DEPLOYMENT_PORT_POLICY_DEFAULT"
DEPLOYMENT_IMAGE_SOURCE="$DEPLOYMENT_IMAGE_SOURCE_DEFAULT"
DEPLOYMENT_REGISTRY_PROFILE="$DEPLOYMENT_REGISTRY_PROFILE_DEFAULT"
+ DEPLOYMENT_IMAGE_REGISTRY_PREFIX="$DEPLOYMENT_IMAGE_REGISTRY_PREFIX_DEFAULT"
DEPLOYMENT_APP_VERSION="${APP_VERSION:-latest}"
DEPLOYMENT_MONITORING_PROVIDER="$DEPLOYMENT_MONITORING_PROVIDER_DEFAULT"
DEPLOYMENT_USE_LOCAL_CONFIG="false"
@@ -704,6 +717,7 @@ deployment_init_defaults() {
DEPLOYMENT_CONFIG_VALUES_LOADED="false"
DEPLOYMENT_DOCKER_PORTS=""
unset DEPLOYMENT_COMPONENTS_EXPLICIT DEPLOYMENT_PORT_POLICY_EXPLICIT DEPLOYMENT_REGISTRY_PROFILE_EXPLICIT
+ unset DEPLOYMENT_IMAGE_REGISTRY_PREFIX_EXPLICIT
unset DEPLOYMENT_MONITORING_PROVIDER_EXPLICIT DEPLOYMENT_IMAGE_SOURCE_EXPLICIT DEPLOYMENT_APP_VERSION_EXPLICIT
}
@@ -726,6 +740,10 @@ deployment_parse_common_args() {
DEPLOYMENT_REGISTRY_PROFILE="$2"
shift 2
;;
+ --image-registry-prefix|--registry-prefix|--image-registry)
+ DEPLOYMENT_IMAGE_REGISTRY_PREFIX="$2"
+ shift 2
+ ;;
--app-version|--version)
DEPLOYMENT_APP_VERSION="$2"
shift 2
@@ -825,6 +843,10 @@ deployment_load_config_file() {
DEPLOYMENT_REGISTRY_PROFILE="$value"
loaded_config_value="true"
;;
+ imageRegistryPrefix)
+ DEPLOYMENT_IMAGE_REGISTRY_PREFIX="$value"
+ loaded_config_value="true"
+ ;;
monitoringProvider)
DEPLOYMENT_MONITORING_PROVIDER="$value"
loaded_config_value="true"
@@ -910,6 +932,40 @@ deployment_normalize_image_source() {
esac
}
+deployment_normalize_image_registry_prefix_value() {
+ local prefix="$1"
+ prefix="$(deployment_trim "$prefix")"
+ prefix="${prefix#http://}"
+ prefix="${prefix#https://}"
+ while [[ "$prefix" == */ ]]; do
+ prefix="${prefix%/}"
+ done
+ printf '%s' "$prefix"
+}
+
+deployment_normalize_image_registry_prefix() {
+ DEPLOYMENT_IMAGE_REGISTRY_PREFIX="$(deployment_normalize_image_registry_prefix_value "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX")"
+}
+
+deployment_add_image_registry_prefix() {
+ local image="$1"
+ local prefix="${2:-$DEPLOYMENT_IMAGE_REGISTRY_PREFIX}"
+ prefix="$(deployment_normalize_image_registry_prefix_value "$prefix")"
+
+ if [ -z "$prefix" ]; then
+ printf '%s' "$image"
+ return 0
+ fi
+ case "$image" in
+ "$prefix"/*)
+ printf '%s' "$image"
+ ;;
+ *)
+ printf '%s/%s' "$prefix" "$image"
+ ;;
+ esac
+}
+
deployment_ensure_required_components() {
local source_components="$DEPLOYMENT_COMPONENTS"
local normalized=""
@@ -972,6 +1028,10 @@ deployment_validate() {
deployment_error "$(deployment_i18n validation.unsupported_registry_profile "$DEPLOYMENT_REGISTRY_PROFILE")"
return 1
}
+ if [[ "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" == *[[:space:]]* ]]; then
+ deployment_error "$(deployment_i18n validation.unsupported_image_registry_prefix "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX")"
+ return 1
+ fi
deployment_is_valid_value "$DEPLOYMENT_MONITORING_PROVIDER" $deployment_monitoring_provider_list || {
deployment_error "$(deployment_i18n validation.unsupported_monitoring_provider "$DEPLOYMENT_MONITORING_PROVIDER")"
return 1
@@ -1599,6 +1659,7 @@ deployment_render_image_rollout_checksums() {
deployment_apply_image_source() {
local version="${DEPLOYMENT_APP_VERSION:-latest}"
+ local image_var
if [ "$DEPLOYMENT_IMAGE_SOURCE" = "local-latest" ]; then
export NEXENT_IMAGE="nexent/nexent:latest"
@@ -1620,6 +1681,47 @@ deployment_apply_image_source() {
export SUPABASE_KONG="${SUPABASE_KONG:-kong:2.8.1}"
export SUPABASE_GOTRUE="${SUPABASE_GOTRUE:-supabase/gotrue:v2.170.0}"
export SUPABASE_DB="${SUPABASE_DB:-supabase/postgres:15.8.1.060}"
+
+ export OTEL_COLLECTOR_IMAGE="${OTEL_COLLECTOR_IMAGE:-otel/opentelemetry-collector-contrib:0.151.0}"
+ export PHOENIX_IMAGE="${PHOENIX_IMAGE:-arizephoenix/phoenix:15}"
+ export TEMPO_IMAGE="${TEMPO_IMAGE:-grafana/tempo:2.10.5}"
+ export GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:12.4}"
+ export ZIPKIN_IMAGE="${ZIPKIN_IMAGE:-openzipkin/zipkin:latest}"
+ export LANGFUSE_WORKER_IMAGE="${LANGFUSE_WORKER_IMAGE:-docker.io/langfuse/langfuse-worker:3}"
+ export LANGFUSE_WEB_IMAGE="${LANGFUSE_WEB_IMAGE:-docker.io/langfuse/langfuse:3}"
+ export CLICKHOUSE_IMAGE="${CLICKHOUSE_IMAGE:-docker.io/clickhouse/clickhouse-server:26.3-alpine}"
+ export LANGFUSE_MINIO_IMAGE="${LANGFUSE_MINIO_IMAGE:-docker.io/minio/minio:RELEASE.2023-12-20T01-00-02Z}"
+ export LANGFUSE_REDIS_IMAGE="${LANGFUSE_REDIS_IMAGE:-docker.io/redis:alpine}"
+ export LANGFUSE_POSTGRES_IMAGE="${LANGFUSE_POSTGRES_IMAGE:-docker.io/postgres:15-alpine}"
+
+ if [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ]; then
+ for image_var in \
+ NEXENT_IMAGE \
+ NEXENT_WEB_IMAGE \
+ NEXENT_DATA_PROCESS_IMAGE \
+ NEXENT_MCP_DOCKER_IMAGE \
+ ELASTICSEARCH_IMAGE \
+ POSTGRESQL_IMAGE \
+ REDIS_IMAGE \
+ MINIO_IMAGE \
+ OPENSSH_SERVER_IMAGE \
+ SUPABASE_KONG \
+ SUPABASE_GOTRUE \
+ SUPABASE_DB \
+ OTEL_COLLECTOR_IMAGE \
+ PHOENIX_IMAGE \
+ TEMPO_IMAGE \
+ GRAFANA_IMAGE \
+ ZIPKIN_IMAGE \
+ LANGFUSE_WORKER_IMAGE \
+ LANGFUSE_WEB_IMAGE \
+ CLICKHOUSE_IMAGE \
+ LANGFUSE_MINIO_IMAGE \
+ LANGFUSE_REDIS_IMAGE \
+ LANGFUSE_POSTGRES_IMAGE; do
+ export "$image_var=$(deployment_add_image_registry_prefix "${!image_var}")"
+ done
+ fi
}
deployment_monitoring_enabled() {
@@ -1674,8 +1776,12 @@ deployment_monitoring_dashboard_url() {
deployment_render_docker_env() {
local output_file="$1"
+ local compose_image_registry_prefix=""
+ [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && compose_image_registry_prefix="${DEPLOYMENT_IMAGE_REGISTRY_PREFIX}/"
mkdir -p "$(dirname "$output_file")"
{
+ printf 'DEPLOYMENT_IMAGE_REGISTRY_PREFIX="%s"\n' "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX"
+ printf 'NEXENT_IMAGE_REGISTRY_PREFIX="%s"\n' "$compose_image_registry_prefix"
printf 'NEXENT_IMAGE="%s"\n' "$NEXENT_IMAGE"
printf 'NEXENT_WEB_IMAGE="%s"\n' "$NEXENT_WEB_IMAGE"
printf 'NEXENT_DATA_PROCESS_IMAGE="%s"\n' "$NEXENT_DATA_PROCESS_IMAGE"
@@ -1688,6 +1794,17 @@ deployment_render_docker_env() {
printf 'SUPABASE_KONG="%s"\n' "$SUPABASE_KONG"
printf 'SUPABASE_GOTRUE="%s"\n' "$SUPABASE_GOTRUE"
printf 'SUPABASE_DB="%s"\n' "$SUPABASE_DB"
+ printf 'OTEL_COLLECTOR_IMAGE="%s"\n' "$OTEL_COLLECTOR_IMAGE"
+ printf 'PHOENIX_IMAGE="%s"\n' "$PHOENIX_IMAGE"
+ printf 'TEMPO_IMAGE="%s"\n' "$TEMPO_IMAGE"
+ printf 'GRAFANA_IMAGE="%s"\n' "$GRAFANA_IMAGE"
+ printf 'ZIPKIN_IMAGE="%s"\n' "$ZIPKIN_IMAGE"
+ printf 'LANGFUSE_WORKER_IMAGE="%s"\n' "$LANGFUSE_WORKER_IMAGE"
+ printf 'LANGFUSE_WEB_IMAGE="%s"\n' "$LANGFUSE_WEB_IMAGE"
+ printf 'CLICKHOUSE_IMAGE="%s"\n' "$CLICKHOUSE_IMAGE"
+ printf 'LANGFUSE_MINIO_IMAGE="%s"\n' "$LANGFUSE_MINIO_IMAGE"
+ printf 'LANGFUSE_REDIS_IMAGE="%s"\n' "$LANGFUSE_REDIS_IMAGE"
+ printf 'LANGFUSE_POSTGRES_IMAGE="%s"\n' "$LANGFUSE_POSTGRES_IMAGE"
} > "$output_file"
}
@@ -1704,7 +1821,7 @@ deployment_render_component_values() {
deployment_render_image_values() {
local local_pull_policy="IfNotPresent"
- [ "$DEPLOYMENT_IMAGE_SOURCE" = "local-latest" ] && local_pull_policy="Never"
+ [ "$DEPLOYMENT_IMAGE_SOURCE" = "local-latest" ] && [ -z "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && local_pull_policy="Never"
printf 'nexent-config:\n'
printf ' images:\n backend:\n repository: "%s"\n tag: "%s"\n pullPolicy: "%s"\n' "$(deployment_image_repo "$NEXENT_IMAGE")" "$(deployment_image_tag "$NEXENT_IMAGE")" "$local_pull_policy"
@@ -1786,7 +1903,7 @@ deployment_render_helm_chart_values() {
local local_pull_policy="IfNotPresent"
local northbound_type="NodePort"
local internal_type="ClusterIP"
- [ "$DEPLOYMENT_IMAGE_SOURCE" = "local-latest" ] && local_pull_policy="Never"
+ [ "$DEPLOYMENT_IMAGE_SOURCE" = "local-latest" ] && [ -z "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && local_pull_policy="Never"
if [ "$DEPLOYMENT_PORT_POLICY" = "development" ]; then
internal_type="NodePort"
fi
@@ -1841,6 +1958,7 @@ deployment_render_helm_chart_values() {
printf 'nexent-supabase-auth:\n'
printf ' enabled: %s\n' "$(deployment_chart_enabled supabase)"
printf ' image:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo "$SUPABASE_GOTRUE")" "$(deployment_image_tag "$SUPABASE_GOTRUE")"
+ printf ' initImage:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo "$POSTGRESQL_IMAGE")" "$(deployment_image_tag "$POSTGRESQL_IMAGE")"
printf ' service:\n type: "%s"\n nodePort: 30999\n' "$internal_type"
printf 'nexent-supabase-db:\n'
printf ' enabled: %s\n' "$(deployment_chart_enabled supabase)"
@@ -1889,27 +2007,59 @@ deployment_render_helm_monitoring_global_values() {
printf ' traceMaxItems: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_TRACE_MAX_ITEMS "20")")"
}
+deployment_render_monitoring_image_value() {
+ local key="$1"
+ local repository="$2"
+ local tag="$3"
+ local image
+ image="$(deployment_add_image_registry_prefix "${repository}:${tag}")"
+
+ printf ' %s:\n' "$key"
+ printf ' repository: %s\n' "$(deployment_yaml_quote "$(deployment_image_repo "$image")")"
+ printf ' tag: %s\n' "$(deployment_yaml_quote "$(deployment_image_tag "$image")")"
+}
+
deployment_render_helm_monitoring_chart_values() {
local enabled
local langfuse_nextauth_url
+ local otel_collector_tag
+ local phoenix_tag
+ local tempo_tag
+ local grafana_tag
+ local zipkin_tag
+ local langfuse_tag
+ local clickhouse_tag
+ local minio_tag
+ local redis_tag
+ local postgres_tag
enabled="$(deployment_monitoring_enabled)"
langfuse_nextauth_url="$(deployment_monitoring_env_value K8S_LANGFUSE_NEXTAUTH_URL "http://localhost:${K8S_LANGFUSE_NODE_PORT:-30001}")"
+ otel_collector_tag="$(deployment_monitoring_env_value OTEL_COLLECTOR_VERSION "0.151.0")"
+ phoenix_tag="$(deployment_monitoring_env_value PHOENIX_VERSION "15")"
+ tempo_tag="$(deployment_monitoring_env_value TEMPO_VERSION "2.10.5")"
+ grafana_tag="$(deployment_monitoring_env_value GRAFANA_VERSION "12.4")"
+ zipkin_tag="$(deployment_monitoring_env_value ZIPKIN_VERSION "latest")"
+ langfuse_tag="$(deployment_monitoring_env_value LANGFUSE_VERSION "3")"
+ clickhouse_tag="$(deployment_monitoring_env_value LANGFUSE_CLICKHOUSE_VERSION "26.3-alpine")"
+ minio_tag="$(deployment_monitoring_env_value LANGFUSE_MINIO_VERSION "RELEASE.2023-12-20T01-00-02Z")"
+ redis_tag="$(deployment_monitoring_env_value LANGFUSE_REDIS_VERSION "alpine")"
+ postgres_tag="$(deployment_monitoring_env_value LANGFUSE_POSTGRES_VERSION "15-alpine")"
printf 'nexent-monitoring:\n'
printf ' enabled: %s\n' "$enabled"
printf ' provider: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_PROVIDER "$DEPLOYMENT_MONITORING_PROVIDER")")"
printf ' images:\n'
- printf ' otelCollector:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value OTEL_COLLECTOR_VERSION "0.151.0")")"
- printf ' phoenix:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value PHOENIX_VERSION "15")")"
- printf ' tempo:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value TEMPO_VERSION "2.10.5")")"
- printf ' grafana:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value GRAFANA_VERSION "12.4")")"
- printf ' zipkin:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value ZIPKIN_VERSION "latest")")"
- printf ' langfuseWeb:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGFUSE_VERSION "3")")"
- printf ' langfuseWorker:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGFUSE_VERSION "3")")"
- printf ' clickhouse:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGFUSE_CLICKHOUSE_VERSION "26.3-alpine")")"
- printf ' minio:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGFUSE_MINIO_VERSION "RELEASE.2023-12-20T01-00-02Z")")"
- printf ' redis:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGFUSE_REDIS_VERSION "alpine")")"
- printf ' postgres:\n tag: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGFUSE_POSTGRES_VERSION "15-alpine")")"
+ deployment_render_monitoring_image_value otelCollector otel/opentelemetry-collector-contrib "$otel_collector_tag"
+ deployment_render_monitoring_image_value phoenix arizephoenix/phoenix "$phoenix_tag"
+ deployment_render_monitoring_image_value tempo grafana/tempo "$tempo_tag"
+ deployment_render_monitoring_image_value grafana grafana/grafana "$grafana_tag"
+ deployment_render_monitoring_image_value zipkin openzipkin/zipkin "$zipkin_tag"
+ deployment_render_monitoring_image_value langfuseWeb docker.io/langfuse/langfuse "$langfuse_tag"
+ deployment_render_monitoring_image_value langfuseWorker docker.io/langfuse/langfuse-worker "$langfuse_tag"
+ deployment_render_monitoring_image_value clickhouse docker.io/clickhouse/clickhouse-server "$clickhouse_tag"
+ deployment_render_monitoring_image_value minio docker.io/minio/minio "$minio_tag"
+ deployment_render_monitoring_image_value redis docker.io/redis "$redis_tag"
+ deployment_render_monitoring_image_value postgres docker.io/postgres "$postgres_tag"
printf ' collector:\n'
printf ' configFile: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value OTEL_COLLECTOR_CONFIG_FILE "$(deployment_monitoring_collector_config_file k8s "$DEPLOYMENT_MONITORING_PROVIDER")")")"
printf ' service:\n'
@@ -1987,6 +2137,7 @@ deployment_render_helm_values() {
fi
printf ' portPolicy: "%s"\n' "$DEPLOYMENT_PORT_POLICY"
printf ' imageSource: "%s"\n' "$DEPLOYMENT_IMAGE_SOURCE"
+ printf ' imageRegistryPrefix: "%s"\n' "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX"
deployment_render_helm_monitoring_global_values
deployment_render_helm_monitoring_chart_values
deployment_render_helm_chart_values
@@ -2009,6 +2160,7 @@ deployment_persist_local_config() {
IFS="$old_ifs"
printf 'portPolicy: "%s"\n' "$DEPLOYMENT_PORT_POLICY"
printf 'imageSource: "%s"\n' "$DEPLOYMENT_IMAGE_SOURCE"
+ printf 'imageRegistryPrefix: "%s"\n' "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX"
printf 'monitoringProvider: "%s"\n' "$DEPLOYMENT_MONITORING_PROVIDER"
} > "$output_file"
}
@@ -2019,6 +2171,9 @@ deployment_print_summary() {
deployment_log "$(deployment_i18n summary.components "$DEPLOYMENT_COMPONENTS")"
deployment_log "$(deployment_i18n summary.port_policy "$DEPLOYMENT_PORT_POLICY")"
deployment_log "$(deployment_i18n summary.image_source "$DEPLOYMENT_IMAGE_SOURCE")"
+ if [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ]; then
+ deployment_log "$(deployment_i18n summary.image_registry_prefix "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX")"
+ fi
if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "monitoring"; then
deployment_log "$(deployment_i18n summary.monitoring_provider "$DEPLOYMENT_MONITORING_PROVIDER")"
fi
@@ -2051,6 +2206,7 @@ deployment_prepare_config() {
--port-policy) DEPLOYMENT_PORT_POLICY_EXPLICIT="true" ;;
--image-source) DEPLOYMENT_IMAGE_SOURCE_EXPLICIT="true" ;;
--registry-profile) DEPLOYMENT_REGISTRY_PROFILE_EXPLICIT="true" ;;
+ --image-registry-prefix|--registry-prefix|--image-registry) DEPLOYMENT_IMAGE_REGISTRY_PREFIX_EXPLICIT="true" ;;
--app-version|--version) DEPLOYMENT_APP_VERSION_EXPLICIT="true" ;;
--monitoring-provider) DEPLOYMENT_MONITORING_PROVIDER_EXPLICIT="true" ;;
--config) DEPLOYMENT_RECONFIGURE="true" ;;
@@ -2076,6 +2232,7 @@ deployment_prepare_config() {
deployment_run_tui_configuration || tui_result=$?
[ "$tui_result" -eq 0 ] || return "$tui_result"
deployment_normalize_image_source || return 1
+ deployment_normalize_image_registry_prefix
deployment_validate || return 1
deployment_compute_selection
}
diff --git a/deploy/docker/compose/docker-compose-monitoring.yml b/deploy/docker/compose/docker-compose-monitoring.yml
index eea556012..34722918a 100644
--- a/deploy/docker/compose/docker-compose-monitoring.yml
+++ b/deploy/docker/compose/docker-compose-monitoring.yml
@@ -2,7 +2,7 @@ name: monitor
services:
otel-collector:
- image: otel/opentelemetry-collector-contrib:${OTEL_COLLECTOR_VERSION:-0.151.0}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}otel/opentelemetry-collector-contrib:${OTEL_COLLECTOR_VERSION:-0.151.0}
container_name: nexent-otel-collector
command: ["--config=/etc/otel-collector-config.yml"]
environment:
@@ -20,7 +20,7 @@ services:
restart: unless-stopped
phoenix:
- image: arizephoenix/phoenix:${PHOENIX_VERSION:-15}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}arizephoenix/phoenix:${PHOENIX_VERSION:-15}
container_name: nexent-phoenix
profiles: ["phoenix"]
environment:
@@ -35,7 +35,7 @@ services:
restart: unless-stopped
tempo:
- image: grafana/tempo:${TEMPO_VERSION:-2.10.5}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}grafana/tempo:${TEMPO_VERSION:-2.10.5}
container_name: nexent-tempo
profiles: ["grafana"]
command: ["--config.file=/etc/tempo.yml"]
@@ -49,7 +49,7 @@ services:
restart: unless-stopped
grafana:
- image: grafana/grafana:${GRAFANA_VERSION:-12.4}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}grafana/grafana:${GRAFANA_VERSION:-12.4}
container_name: nexent-grafana
profiles: ["grafana"]
environment:
@@ -71,7 +71,7 @@ services:
restart: unless-stopped
zipkin:
- image: openzipkin/zipkin:${ZIPKIN_VERSION:-latest}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}openzipkin/zipkin:${ZIPKIN_VERSION:-latest}
container_name: nexent-zipkin
profiles: ["zipkin"]
ports:
@@ -81,7 +81,7 @@ services:
restart: unless-stopped
langfuse-worker:
- image: docker.io/langfuse/langfuse-worker:${LANGFUSE_VERSION:-3}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}docker.io/langfuse/langfuse-worker:${LANGFUSE_VERSION:-3}
container_name: nexent-langfuse-worker
profiles: ["langfuse"]
restart: unless-stopped
@@ -139,7 +139,7 @@ services:
- nexent
langfuse-web:
- image: docker.io/langfuse/langfuse:${LANGFUSE_VERSION:-3}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}docker.io/langfuse/langfuse:${LANGFUSE_VERSION:-3}
container_name: nexent-langfuse-web
profiles: ["langfuse"]
restart: unless-stopped
@@ -161,7 +161,7 @@ services:
- nexent
langfuse-clickhouse:
- image: docker.io/clickhouse/clickhouse-server:${LANGFUSE_CLICKHOUSE_VERSION:-26.3-alpine}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}docker.io/clickhouse/clickhouse-server:${LANGFUSE_CLICKHOUSE_VERSION:-26.3-alpine}
container_name: nexent-langfuse-clickhouse
profiles: ["langfuse"]
restart: unless-stopped
@@ -186,7 +186,7 @@ services:
- nexent
langfuse-minio:
- image: docker.io/minio/minio:${LANGFUSE_MINIO_VERSION:-RELEASE.2023-12-20T01-00-02Z}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}docker.io/minio/minio:${LANGFUSE_MINIO_VERSION:-RELEASE.2023-12-20T01-00-02Z}
container_name: nexent-langfuse-minio
profiles: ["langfuse"]
restart: unless-stopped
@@ -210,7 +210,7 @@ services:
- nexent
langfuse-redis:
- image: docker.io/redis:${LANGFUSE_REDIS_VERSION:-alpine}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}docker.io/redis:${LANGFUSE_REDIS_VERSION:-alpine}
container_name: nexent-langfuse-redis
profiles: ["langfuse"]
restart: unless-stopped
@@ -230,7 +230,7 @@ services:
- nexent
langfuse-postgres:
- image: docker.io/postgres:${LANGFUSE_POSTGRES_VERSION:-15-alpine}
+ image: ${NEXENT_IMAGE_REGISTRY_PREFIX:-}docker.io/postgres:${LANGFUSE_POSTGRES_VERSION:-15-alpine}
container_name: nexent-langfuse-postgres
profiles: ["langfuse"]
restart: unless-stopped
diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh
index 7c038bea4..dda77c0bb 100755
--- a/deploy/docker/deploy.sh
+++ b/deploy/docker/deploy.sh
@@ -67,6 +67,7 @@ print_docker_deploy_usage() {
echo " --port-policy POLICY development 或 production"
echo " --image-source SOURCE general、mainland 或 local-latest"
echo " --registry-profile NAME 兼容旧参数,映射为 general/mainland 镜像源"
+ echo " --image-registry-prefix P 镜像仓库前缀,例如 registry.example.com/nexent"
echo " --monitoring-provider NAME 选中 monitoring 组件时使用的监控 provider"
echo " --version VERSION 指定应用版本(未设置时自动检测)"
echo " --defaults 复用保存配置或内置默认值并跳过交互界面"
@@ -89,6 +90,7 @@ print_docker_deploy_usage() {
echo " --port-policy POLICY development or production"
echo " --image-source SOURCE general, mainland, or local-latest"
echo " --registry-profile NAME Legacy alias for image source general/mainland"
+ echo " --image-registry-prefix P Image registry prefix, e.g. registry.example.com/nexent"
echo " --monitoring-provider NAME Monitoring provider when monitoring is selected"
echo " --version VERSION Specify app version (auto-detected if not set)"
echo " --defaults Use saved config or built-in defaults and skip TUI"
diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh
index c69dc31d7..933b3e7c6 100755
--- a/deploy/k8s/deploy.sh
+++ b/deploy/k8s/deploy.sh
@@ -545,6 +545,10 @@ persist_deploy_options() {
echo "APP_VERSION=\"${APP_VERSION}\""
echo "IS_MAINLAND=\"${IS_MAINLAND_SAVED}\""
echo "DEPLOYMENT_VERSION=\"${VERSION_CHOICE_SAVED}\""
+ echo "PERSISTENCE_MODE=\"${PERSISTENCE_MODE}\""
+ echo "STORAGE_CLASS_NAME=\"${STORAGE_CLASS_NAME}\""
+ echo "LOCAL_PATH=\"${LOCAL_PATH}\""
+ echo "EXISTING_CLAIM_PREFIX=\"${EXISTING_CLAIM_PREFIX}\""
} > "$DEPLOY_OPTIONS_FILE"
}
@@ -1232,6 +1236,7 @@ print_usage() {
echo " --components LIST 要部署的组件"
echo " --port-policy POLICY development 或 production"
echo " --image-source SOURCE general、mainland 或 local-latest"
+ echo " --image-registry-prefix P 镜像仓库前缀,例如 registry.example.com/nexent"
echo " --is-mainland Y|N 兼容旧参数,映射为 mainland/general 镜像源"
echo " --version VERSION 指定应用版本(未设置时自动从 const.py 检测)"
echo " --defaults 复用保存配置或内置默认值并跳过交互界面"
@@ -1259,6 +1264,7 @@ print_usage() {
echo " --components LIST Components to deploy"
echo " --port-policy POLICY development or production"
echo " --image-source SOURCE general, mainland, or local-latest"
+ echo " --image-registry-prefix P Image registry prefix, e.g. registry.example.com/nexent"
echo " --is-mainland Y|N Legacy alias for image source mainland/general"
echo " --version VERSION Specify app version (auto-detected from const.py if not set)"
echo " --defaults Use saved config or built-in defaults and skip TUI"
diff --git a/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/templates/deployment.yaml
index 47741cb5a..3373b3e2e 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/templates/deployment.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/templates/deployment.yaml
@@ -23,8 +23,8 @@ spec:
spec:
initContainers:
- name: init-db
- image: postgres:15-alpine
- imagePullPolicy: IfNotPresent
+ image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
+ imagePullPolicy: {{ .Values.initImage.pullPolicy }}
env:
- name: DB_HOST
value: {{ .Values.config.postgresHost | quote }}
diff --git a/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/values.yaml
index da15ffbeb..807907515 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-supabase-auth/values.yaml
@@ -5,6 +5,11 @@ image:
tag: v2.170.0
pullPolicy: IfNotPresent
+initImage:
+ repository: postgres
+ tag: 15-alpine
+ pullPolicy: IfNotPresent
+
resources:
requests:
memory: 512Mi
diff --git a/deploy/k8s/helm/nexent/charts/nexent-supabase-db/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-supabase-db/templates/deployment.yaml
index b91d4db4b..00039abdf 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-supabase-db/templates/deployment.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-supabase-db/templates/deployment.yaml
@@ -42,6 +42,8 @@ spec:
cp /custom-supabase-sql/_supabase.sql /initdb.d/migrations/97-_supabase.sql
cp /custom-supabase-sql/pooler.sql /initdb.d/migrations/99-pooler.sql
+ chmod 755 -R /initdb.d/
+
echo "Initialization scripts are ready"
volumeMounts:
- mountPath: /custom-supabase-sql
diff --git a/deploy/offline/build_offline_package.sh b/deploy/offline/build_offline_package.sh
index 1359ae3a5..afae3ead6 100755
--- a/deploy/offline/build_offline_package.sh
+++ b/deploy/offline/build_offline_package.sh
@@ -59,6 +59,8 @@ show_help() {
echo " --components LIST 用于镜像选择的部署组件"
echo " --image-source SOURCE general、mainland 或 local-latest"
echo " --registry-profile NAME 兼容旧参数,映射到 --image-source general|mainland"
+ echo " --image-registry-prefix PREFIX"
+ echo " 使用指定镜像仓库前缀拉取和打包镜像"
echo " --defaults 复用保存配置或内置默认值并跳过交互界面"
echo " --config 进入交互式部署配置界面"
echo " --dry-run 只展示执行计划,不执行实际操作"
@@ -91,6 +93,8 @@ show_help() {
echo " --components LIST Deployment components for image selection"
echo " --image-source SOURCE general, mainland, or local-latest"
echo " --registry-profile NAME Legacy alias for --image-source general|mainland"
+ echo " --image-registry-prefix PREFIX"
+ echo " Pull and package images with this registry prefix"
echo " --defaults Use saved config or built-in defaults and skip TUI"
echo " --config Open the interactive deployment configuration"
echo " --dry-run Show execution plan without actual operations"
@@ -135,7 +139,7 @@ parse_args() {
DRY_RUN="true"
shift
;;
- --components|--image-source|--registry-profile|--app-version|--monitoring-provider|--port-policy|--local-config)
+ --components|--image-source|--registry-profile|--image-registry-prefix|--registry-prefix|--image-registry|--app-version|--monitoring-provider|--port-policy|--local-config)
COMMON_ARGS+=("$1" "$2")
shift 2
;;
@@ -223,6 +227,7 @@ show_dry_run_plan() {
echo "压缩:$COMPRESS"
echo "组件:$DEPLOYMENT_COMPONENTS"
echo "镜像源:$DEPLOYMENT_IMAGE_SOURCE"
+ [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && echo "镜像仓库前缀:$DEPLOYMENT_IMAGE_REGISTRY_PREFIX"
echo ""
echo "将拉取的镜像:"
get_nexent_images
@@ -241,6 +246,7 @@ show_dry_run_plan() {
echo "Compress: $COMPRESS"
echo "Components: $DEPLOYMENT_COMPONENTS"
echo "Image source: $DEPLOYMENT_IMAGE_SOURCE"
+ [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && echo "Image registry prefix: $DEPLOYMENT_IMAGE_REGISTRY_PREFIX"
echo ""
echo "Images to pull:"
get_nexent_images
@@ -260,6 +266,12 @@ get_nexent_images() {
}
get_third_party_images() {
+ local image
+ echo_image_ref() {
+ deployment_add_image_registry_prefix "$1"
+ echo ""
+ }
+
if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "infrastructure"; then
echo "$ELASTICSEARCH_IMAGE"
echo "$POSTGRESQL_IMAGE"
@@ -272,21 +284,24 @@ get_third_party_images() {
echo "$SUPABASE_DB"
fi
if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "monitoring"; then
- echo "otel/opentelemetry-collector-contrib:0.151.0"
+ echo_image_ref "otel/opentelemetry-collector-contrib:0.151.0"
case "$DEPLOYMENT_MONITORING_PROVIDER" in
- phoenix) echo "arizephoenix/phoenix:15" ;;
+ phoenix) echo_image_ref "arizephoenix/phoenix:15" ;;
grafana)
- echo "grafana/tempo:2.10.5"
- echo "grafana/grafana:12.4"
+ echo_image_ref "grafana/tempo:2.10.5"
+ echo_image_ref "grafana/grafana:12.4"
;;
- zipkin) echo "openzipkin/zipkin:latest" ;;
+ zipkin) echo_image_ref "openzipkin/zipkin:latest" ;;
langfuse)
- echo "docker.io/langfuse/langfuse-worker:3"
- echo "docker.io/langfuse/langfuse:3"
- echo "docker.io/clickhouse/clickhouse-server:26.3-alpine"
- echo "docker.io/minio/minio:RELEASE.2023-12-20T01-00-02Z"
- echo "docker.io/redis:alpine"
- echo "docker.io/postgres:15-alpine"
+ for image in \
+ "docker.io/langfuse/langfuse-worker:3" \
+ "docker.io/langfuse/langfuse:3" \
+ "docker.io/clickhouse/clickhouse-server:26.3-alpine" \
+ "docker.io/minio/minio:RELEASE.2023-12-20T01-00-02Z" \
+ "docker.io/redis:alpine" \
+ "docker.io/postgres:15-alpine"; do
+ echo_image_ref "$image"
+ done
;;
esac
fi
@@ -312,11 +327,6 @@ should_skip_pull() {
return 0
fi
- if uses_latest_tag "$image"; then
- echo "Skipping pull for latest image; expecting local image: $image"
- return 0
- fi
-
return 1
}
@@ -514,6 +524,9 @@ copy_source_code() {
done <<< "$git_files"
echo "✅ Git-managed source code copied to: $source_dir"
+ if [ -f "$source_dir/VERSION" ]; then
+ printf '%s\n' "$VERSION" > "$source_dir/VERSION"
+ fi
local total_size
total_size=$(du -sh "$source_dir" | cut -f1)
@@ -522,38 +535,44 @@ copy_source_code() {
return 0
}
-create_load_script() {
+copy_load_script() {
local load_script="$OUTPUT_DIR/load-images.sh"
+ local template_script="$SCRIPT_DIR/load-images.sh"
echo ""
echo "========================================"
- echo "Creating load-images.sh script..."
+ echo "Copying load-images.sh script..."
echo "========================================"
- cat > "$load_script" << 'LOADSCRIPT'
-#!/bin/bash
+ if [ ! -f "$template_script" ]; then
+ echo "❌ load-images.sh template not found: $template_script"
+ return 1
+ fi
-set -e
+ cp "$template_script" "$load_script"
+ chmod +x "$load_script"
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-IMAGES_DIR="$SCRIPT_DIR/images"
+ echo "✅ Created: $load_script"
+}
-echo "Loading Docker images from $IMAGES_DIR..."
+copy_push_script() {
+ local push_script="$OUTPUT_DIR/push-images.sh"
+ local template_script="$SCRIPT_DIR/push-images.sh"
-for tar_file in "$IMAGES_DIR"/*.tar; do
- if [[ -f "$tar_file" ]]; then
- echo "Loading: $tar_file"
- docker load -i "$tar_file"
- fi
-done
+ echo ""
+ echo "========================================"
+ echo "Copying push-images.sh script..."
+ echo "========================================"
-echo ""
-echo "✅ All images loaded successfully"
-LOADSCRIPT
+ if [ ! -f "$template_script" ]; then
+ echo "❌ push-images.sh template not found: $template_script"
+ return 1
+ fi
- chmod +x "$load_script"
+ cp "$template_script" "$push_script"
+ chmod +x "$push_script"
- echo "✅ Created: $load_script"
+ echo "✅ Created: $push_script"
}
create_offline_deploy_entrypoint() {
@@ -581,7 +600,7 @@ copy_deployment_bundle() {
cp "$PROJECT_ROOT/deploy.sh" "$OUTPUT_DIR/deploy.sh"
cp "$PROJECT_ROOT/uninstall.sh" "$OUTPUT_DIR/uninstall.sh"
- cp "$PROJECT_ROOT/VERSION" "$OUTPUT_DIR/VERSION"
+ printf '%s\n' "$VERSION" > "$OUTPUT_DIR/VERSION"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
@@ -610,7 +629,7 @@ copy_deployment_bundle() {
create_offline_deploy_entrypoint
find "$OUTPUT_DIR" -name '.git' -type d -prune -exec rm -rf {} + 2>/dev/null || true
- chmod +x "$OUTPUT_DIR/deploy.sh" "$OUTPUT_DIR/uninstall.sh" "$OUTPUT_DIR/load-images.sh" 2>/dev/null || true
+ chmod +x "$OUTPUT_DIR/deploy.sh" "$OUTPUT_DIR/uninstall.sh" "$OUTPUT_DIR/load-images.sh" "$OUTPUT_DIR/push-images.sh" 2>/dev/null || true
find "$OUTPUT_DIR/deploy" -type f -name '*.sh' -exec chmod +x {} \; 2>/dev/null || true
echo "✅ Deployment bundle copied"
@@ -631,6 +650,7 @@ create_manifest() {
echo "target: \"$TARGET\""
echo "components: \"$DEPLOYMENT_COMPONENTS\""
echo "imageSource: \"$DEPLOYMENT_IMAGE_SOURCE\""
+ echo "imageRegistryPrefix: \"$DEPLOYMENT_IMAGE_REGISTRY_PREFIX\""
echo "images:"
while IFS= read -r image; do
[ -n "$image" ] && echo " - \"$image\""
@@ -723,6 +743,7 @@ main() {
echo "Compress: $COMPRESS"
echo "Components: $DEPLOYMENT_COMPONENTS"
echo "Image source: $DEPLOYMENT_IMAGE_SOURCE"
+ [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && echo "Image registry prefix: $DEPLOYMENT_IMAGE_REGISTRY_PREFIX"
echo "========================================"
rm -rf "$OUTPUT_DIR"
@@ -743,11 +764,16 @@ main() {
exit 1
}
- create_load_script || {
+ copy_load_script || {
echo "❌ Load script creation failed, aborting"
exit 1
}
+ copy_push_script || {
+ echo "❌ Push script creation failed, aborting"
+ exit 1
+ }
+
copy_deployment_bundle || {
echo "❌ Deployment bundle copy failed, aborting"
exit 1
diff --git a/deploy/offline/load-images.sh b/deploy/offline/load-images.sh
new file mode 100755
index 000000000..d8045c122
--- /dev/null
+++ b/deploy/offline/load-images.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+IMAGES_DIR="$SCRIPT_DIR/images"
+
+echo "Loading Docker images from $IMAGES_DIR..."
+
+for tar_file in "$IMAGES_DIR"/*.tar; do
+ if [[ -f "$tar_file" ]]; then
+ echo "Loading: $tar_file"
+ docker load -i "$tar_file"
+ fi
+done
+
+echo ""
+echo "✅ All images loaded successfully"
diff --git a/deploy/offline/push-images.sh b/deploy/offline/push-images.sh
new file mode 100755
index 000000000..9567490b0
--- /dev/null
+++ b/deploy/offline/push-images.sh
@@ -0,0 +1,223 @@
+#!/bin/bash
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MANIFEST_FILE="$SCRIPT_DIR/manifest.yaml"
+LOAD_SCRIPT="$SCRIPT_DIR/load-images.sh"
+
+IMAGE_REGISTRY_PREFIX="${IMAGE_REGISTRY_PREFIX:-}"
+REGISTRY_USERNAME="${REGISTRY_USERNAME:-}"
+REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-}"
+LOAD_IMAGES="false"
+PROMPT_FOR_MISSING="true"
+OUTPUT_ENV_FILE=""
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ bash push-images.sh [--image-registry-prefix registry.example.com/nexent] [options]
+
+Options:
+ --image-registry-prefix PREFIX Registry prefix used for pushed image tags. Prompts when omitted.
+ --registry-username USER Username for docker login. Prompts when omitted.
+ --registry-password PASSWORD Password for docker login. REGISTRY_PASSWORD is also supported.
+ --registry-password-stdin Read docker login password from standard input.
+ --load-images Load images from ./images before pushing.
+ --no-prompt Fail instead of prompting for missing values.
+ --output-env-file FILE Write selected image registry prefix to FILE.
+ --help Show this help message.
+USAGE
+}
+
+normalize_image_registry_prefix() {
+ local prefix="$1"
+ prefix="${prefix#"${prefix%%[![:space:]]*}"}"
+ prefix="${prefix%"${prefix##*[![:space:]]}"}"
+ prefix="${prefix#http://}"
+ prefix="${prefix#https://}"
+ while [[ "$prefix" == */ ]]; do
+ prefix="${prefix%/}"
+ done
+ printf '%s' "$prefix"
+}
+
+prefixed_image_ref() {
+ local image="$1"
+ local prefix="$2"
+ if [ -z "$prefix" ]; then
+ printf '%s' "$image"
+ return 0
+ fi
+ case "$image" in
+ "$prefix"/*) printf '%s' "$image" ;;
+ *) printf '%s/%s' "$prefix" "$image" ;;
+ esac
+}
+
+registry_host_from_prefix() {
+ local prefix="$1"
+ printf '%s' "${prefix%%/*}"
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --image-registry-prefix|--registry-prefix|--image-registry)
+ if [ $# -lt 2 ]; then
+ echo "Error: $1 requires a value." >&2
+ exit 1
+ fi
+ IMAGE_REGISTRY_PREFIX="$2"
+ shift 2
+ ;;
+ --registry-username)
+ if [ $# -lt 2 ]; then
+ echo "Error: $1 requires a value." >&2
+ exit 1
+ fi
+ REGISTRY_USERNAME="$2"
+ shift 2
+ ;;
+ --registry-password)
+ if [ $# -lt 2 ]; then
+ echo "Error: $1 requires a value." >&2
+ exit 1
+ fi
+ REGISTRY_PASSWORD="$2"
+ shift 2
+ ;;
+ --registry-password-stdin)
+ REGISTRY_PASSWORD="$(cat)"
+ shift
+ ;;
+ --load-images)
+ LOAD_IMAGES="true"
+ shift
+ ;;
+ --no-prompt)
+ PROMPT_FOR_MISSING="false"
+ shift
+ ;;
+ --output-env-file)
+ if [ $# -lt 2 ]; then
+ echo "Error: $1 requires a value." >&2
+ exit 1
+ fi
+ OUTPUT_ENV_FILE="$2"
+ shift 2
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [ -z "$IMAGE_REGISTRY_PREFIX" ]; then
+ if [ -t 0 ]; then
+ if [ "$PROMPT_FOR_MISSING" = "true" ]; then
+ read -r -p "Enter image registry prefix (e.g. registry.example.com/nexent): " IMAGE_REGISTRY_PREFIX
+ else
+ echo "Error: --image-registry-prefix is required." >&2
+ exit 1
+ fi
+ else
+ echo "Error: --image-registry-prefix is required." >&2
+ exit 1
+ fi
+fi
+
+IMAGE_REGISTRY_PREFIX="$(normalize_image_registry_prefix "$IMAGE_REGISTRY_PREFIX")"
+if [ -z "$IMAGE_REGISTRY_PREFIX" ]; then
+ echo "Error: image registry prefix cannot be empty." >&2
+ exit 1
+fi
+
+if [ -z "$REGISTRY_USERNAME" ]; then
+ if [ -t 0 ]; then
+ if [ "$PROMPT_FOR_MISSING" = "true" ]; then
+ read -r -p "Registry username: " REGISTRY_USERNAME
+ else
+ echo "Error: --registry-username is required when pushing images." >&2
+ exit 1
+ fi
+ else
+ echo "Error: --registry-username is required when pushing images." >&2
+ exit 1
+ fi
+fi
+
+if [ -z "$REGISTRY_USERNAME" ]; then
+ echo "Error: registry username cannot be empty." >&2
+ exit 1
+fi
+
+if [ -z "$REGISTRY_PASSWORD" ]; then
+ if [ -t 0 ]; then
+ if [ "$PROMPT_FOR_MISSING" = "true" ]; then
+ read -r -s -p "Registry password: " REGISTRY_PASSWORD
+ echo ""
+ else
+ echo "Error: registry password is required when pushing images." >&2
+ exit 1
+ fi
+ else
+ echo "Error: registry password is required when pushing images." >&2
+ exit 1
+ fi
+fi
+
+if [ -z "$REGISTRY_PASSWORD" ]; then
+ echo "Error: registry password cannot be empty." >&2
+ exit 1
+fi
+
+if [ ! -f "$MANIFEST_FILE" ]; then
+ echo "Error: manifest not found: $MANIFEST_FILE" >&2
+ exit 1
+fi
+
+registry_host="$(registry_host_from_prefix "$IMAGE_REGISTRY_PREFIX")"
+printf '%s' "$REGISTRY_PASSWORD" | docker login "$registry_host" --username "$REGISTRY_USERNAME" --password-stdin
+
+if [ -n "$OUTPUT_ENV_FILE" ]; then
+ mkdir -p "$(dirname "$OUTPUT_ENV_FILE")"
+ printf 'IMAGE_REGISTRY_PREFIX=%q\n' "$IMAGE_REGISTRY_PREFIX" > "$OUTPUT_ENV_FILE"
+fi
+
+if [ "$LOAD_IMAGES" = "true" ]; then
+ if [ ! -f "$LOAD_SCRIPT" ]; then
+ echo "Error: --load-images requires $LOAD_SCRIPT" >&2
+ exit 1
+ fi
+ bash "$LOAD_SCRIPT"
+fi
+
+echo "Pushing images with prefix: $IMAGE_REGISTRY_PREFIX"
+
+awk -F'"' '/^[[:space:]]*-[[:space:]]*"/ { print $2 }' "$MANIFEST_FILE" | while IFS= read -r image; do
+ [ -n "$image" ] || continue
+ target_image="$(prefixed_image_ref "$image" "$IMAGE_REGISTRY_PREFIX")"
+
+ if ! docker image inspect "$image" >/dev/null 2>&1; then
+ echo "Error: image is not loaded locally: $image" >&2
+ echo "Run bash load-images.sh first, or use --load-images." >&2
+ exit 1
+ fi
+
+ if [ "$target_image" != "$image" ]; then
+ echo "Tagging: $image -> $target_image"
+ docker tag "$image" "$target_image"
+ fi
+
+ echo "Pushing: $target_image"
+ docker push "$target_image"
+done
+
+echo ""
+echo "✅ All images pushed successfully"
diff --git a/deploy/tests/test_build_offline_package.sh b/deploy/tests/test_build_offline_package.sh
index cd62a68fd..e4673b58d 100755
--- a/deploy/tests/test_build_offline_package.sh
+++ b/deploy/tests/test_build_offline_package.sh
@@ -41,6 +41,15 @@ case "$1" in
[ -n "${FAKE_DOCKER_LOG:-}" ] && printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
exit 0
;;
+ login)
+ password="$(cat)"
+ [ -n "${FAKE_DOCKER_LOG:-}" ] && printf '%s password=%s\n' "$*" "$password" >> "$FAKE_DOCKER_LOG"
+ exit 0
+ ;;
+ load|tag|push)
+ [ -n "${FAKE_DOCKER_LOG:-}" ] && printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
+ exit 0
+ ;;
save)
[ -n "${FAKE_DOCKER_LOG:-}" ] && printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
out=""
@@ -70,6 +79,7 @@ assert_common_package_files() {
[ ! -f "$package_dir/install.sh" ] || fail "install.sh should not be packaged"
[ ! -f "$package_dir/offline-install.sh" ] || fail "offline-install.sh should not be packaged"
[ -f "$package_dir/load-images.sh" ] || fail "load-images.sh should be packaged"
+ [ -f "$package_dir/push-images.sh" ] || fail "push-images.sh should be packaged"
[ -f "$package_dir/manifest.yaml" ] || fail "manifest.yaml should be packaged"
[ -f "$package_dir/checksums.txt" ] || fail "checksums.txt should be packaged"
[ -f "$package_dir/deploy/deploy.sh" ] || fail "deploy/deploy.sh should be packaged"
@@ -95,6 +105,13 @@ assert_common_package_files() {
create_fake_docker
+WORKFLOW_CONTENT="$(cat "$PROJECT_ROOT/.github/workflows/build-offline-package.yml")"
+echo "$WORKFLOW_CONTENT" | grep -q 'SOURCE_SUFFIX="-with-source"' || fail "offline package workflow should append with-source when source is included"
+echo "$WORKFLOW_CONTENT" | grep -q 'package-name=nexent-${VERSION}-${PLATFORM}${SOURCE_SUFFIX}' || fail "offline package workflow package name should include source suffix"
+echo "$WORKFLOW_CONTENT" | grep -q -- '--compress false' || fail "offline package workflow should let GitHub create the final artifact zip"
+echo "$WORKFLOW_CONTENT" | grep -q 'path: ./offline-output' || fail "offline package workflow should upload package contents, not an inner zip"
+! echo "$WORKFLOW_CONTENT" | grep -q 'path: .*package-name.*\\.zip' || fail "offline package workflow should not upload a pre-compressed zip"
+
for target in docker k8s all; do
package_dir="$OUT_DIR/$target"
PATH="$BIN_DIR:$PATH" \
@@ -108,6 +125,7 @@ for target in docker k8s all; do
--output-dir "$package_dir" >/tmp/nexent-offline-package-${target}.log
assert_common_package_files "$package_dir"
+ [ "$(cat "$package_dir/VERSION")" = "v2.2.0" ] || fail "root VERSION should match requested package version for target $target"
[ -f "$OUT_DIR/nexent-offline-${target}-amd64-v2.2.0.zip" ] || fail "zip package should be created for target $target"
grep -q "target: \"$target\"" "$package_dir/manifest.yaml" || fail "manifest should record target $target"
grep -q "nexent/nexent:v2.2.0" "$package_dir/manifest.yaml" || fail "manifest should include Nexent image"
@@ -136,6 +154,12 @@ cat > "$deploy_wrapper_dir/load-images.sh" <<'SH'
printf 'load-images\n' >> "$DEPLOY_WRAPPER_LOG"
SH
chmod +x "$deploy_wrapper_dir/load-images.sh"
+cat > "$deploy_wrapper_dir/push-images.sh" <<'SH'
+#!/usr/bin/env bash
+args=("$@")
+printf 'push:%s:%s\n' "${REGISTRY_PASSWORD:-}" "${args[*]}" >> "$DEPLOY_WRAPPER_LOG"
+SH
+chmod +x "$deploy_wrapper_dir/push-images.sh"
cat > "$deploy_wrapper_dir/deploy/deploy.sh" <<'SH'
#!/usr/bin/env bash
printf 'deploy:%s:%s\n' "${NEXENT_DEPLOY_CONFIG_MODE:-}" "$*" >> "$DEPLOY_WRAPPER_LOG"
@@ -164,6 +188,24 @@ grep -q '^deploy:defaults:docker --foo bar$' "$deploy_wrapper_log" || fail "depl
DEPLOY_WRAPPER_LOG="$deploy_wrapper_log" bash "$deploy_wrapper_dir/deploy.sh" docker --defaults --foo bar
grep -q '^deploy:defaults:docker --foo bar$' "$deploy_wrapper_log" || fail "deploy.sh --defaults after target should enable defaults mode and consume the flag"
+: > "$deploy_wrapper_log"
+DEPLOY_WRAPPER_LOG="$deploy_wrapper_log" REGISTRY_USERNAME=user REGISTRY_PASSWORD=secret bash "$deploy_wrapper_dir/deploy.sh" --push-images --image-registry-prefix registry.local/nexent docker --foo bar
+first_line="$(sed -n '1p' "$deploy_wrapper_log")"
+second_line="$(sed -n '2p' "$deploy_wrapper_log")"
+[[ "$first_line" == "push:secret:--image-registry-prefix registry.local/nexent --load-images" ]] || fail "deploy.sh --push-images should delegate push args to push-images.sh"
+[ "$second_line" = "deploy::docker --foo bar --image-registry-prefix registry.local/nexent" ] || fail "deploy.sh --push-images should forward image registry prefix to deploy config"
+: > "$deploy_wrapper_log"
+DEPLOY_WRAPPER_LOG="$deploy_wrapper_log" REGISTRY_USERNAME=user REGISTRY_PASSWORD=secret bash "$deploy_wrapper_dir/deploy.sh" --load-images --push-images --image-registry-prefix registry.local/nexent docker --foo bar
+first_line="$(sed -n '1p' "$deploy_wrapper_log")"
+second_line="$(sed -n '2p' "$deploy_wrapper_log")"
+[[ "$first_line" == "push:secret:--image-registry-prefix registry.local/nexent --load-images" ]] || fail "deploy.sh --load-images --push-images should not load before push login"
+[ "$second_line" = "deploy::docker --foo bar --image-registry-prefix registry.local/nexent" ] || fail "deploy.sh --load-images --push-images should forward deploy args"
+
+if DEPLOY_WRAPPER_LOG="$deploy_wrapper_log" REGISTRY_USERNAME=user REGISTRY_PASSWORD=secret bash "$deploy_wrapper_dir/deploy.sh" --push-images docker --foo bar >/tmp/nexent-deploy-wrapper-missing-prefix.log 2>&1; then
+ fail "deploy.sh --push-images should require image registry prefix in non-interactive mode"
+fi
+grep -q -- '--image-registry-prefix' /tmp/nexent-deploy-wrapper-missing-prefix.log || fail "deploy.sh missing prefix error should be explicit"
+
latest_package_dir="$OUT_DIR/latest"
latest_pull_log="$TMP_DIR/latest-docker.log"
: > "$latest_pull_log"
@@ -184,15 +226,55 @@ PATH="$BIN_DIR:$PATH" FAKE_DOCKER_LOG="$latest_pull_log" \
--output-dir "$latest_package_dir" >/tmp/nexent-offline-package-latest.log
assert_common_package_files "$latest_package_dir"
+[ "$(cat "$latest_package_dir/VERSION")" = "latest" ] || fail "root VERSION should match requested latest package version"
grep -q '^DEPLOY_WRAPPER_DEFAULT_CONFIG_MODE="defaults"$' "$latest_package_dir/deploy.sh" || fail "offline deploy.sh should reuse the root entrypoint with defaults mode enabled"
offline_help="$(DEPLOYMENT_LANG=en bash "$latest_package_dir/deploy.sh" --help)"
echo "$offline_help" | grep -q "deploys with saved configuration or built-in defaults" || fail "offline deploy help should explain default non-interactive mode"
+push_log="$TMP_DIR/push-images.log"
+: > "$push_log"
+PATH="$BIN_DIR:$PATH" \
+ FAKE_DOCKER_LOG="$push_log" \
+ FAKE_DOCKER_LOCAL_IMAGES="nexent/nexent:latest,nexent/nexent-web:latest,nexent/nexent-mcp:latest,docker.elastic.co/elasticsearch/elasticsearch:8.17.4,postgres:15-alpine,redis:alpine,quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z" \
+ REGISTRY_PASSWORD=secret \
+ bash "$latest_package_dir/push-images.sh" \
+ --image-registry-prefix https://registry.local/nexent/ \
+ --registry-username user \
+ --output-env-file "$TMP_DIR/push-images.env" >/tmp/nexent-offline-package-push.log
+grep -q '^login registry.local --username user --password-stdin password=secret$' "$push_log" || fail "push-images.sh should login to registry host with password stdin"
+grep -q '^IMAGE_REGISTRY_PREFIX=registry.local/nexent$' "$TMP_DIR/push-images.env" || fail "push-images.sh should write selected registry prefix to output env file"
+grep -q '^tag nexent/nexent:latest registry.local/nexent/nexent/nexent:latest$' "$push_log" || fail "push-images.sh should tag Nexent image with registry prefix"
+grep -q '^push registry.local/nexent/nexent/nexent:latest$' "$push_log" || fail "push-images.sh should push prefixed Nexent image"
+grep -q '^tag docker.elastic.co/elasticsearch/elasticsearch:8.17.4 registry.local/nexent/docker.elastic.co/elasticsearch/elasticsearch:8.17.4$' "$push_log" || fail "push-images.sh should tag third-party image with registry prefix"
+: > "$push_log"
+PATH="$BIN_DIR:$PATH" \
+ FAKE_DOCKER_LOG="$push_log" \
+ FAKE_DOCKER_LOCAL_IMAGES="nexent/nexent:latest,nexent/nexent-web:latest,nexent/nexent-mcp:latest,docker.elastic.co/elasticsearch/elasticsearch:8.17.4,postgres:15-alpine,redis:alpine,quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z" \
+ REGISTRY_PASSWORD=secret \
+ bash "$latest_package_dir/push-images.sh" \
+ --load-images \
+ --image-registry-prefix registry.local/nexent \
+ --registry-username user >/tmp/nexent-offline-package-push-load.log
+first_line="$(sed -n '1p' "$push_log")"
+second_line="$(sed -n '2p' "$push_log")"
+[ "$first_line" = "login registry.local --username user --password-stdin password=secret" ] || fail "push-images.sh should docker login before loading images"
+[[ "$second_line" == load\ -i\ * ]] || fail "push-images.sh should load images only after docker login"
+if PATH="$BIN_DIR:$PATH" REGISTRY_PASSWORD=secret bash "$latest_package_dir/push-images.sh" --image-registry-prefix registry.local/nexent >/tmp/nexent-offline-package-push-missing-user.log 2>&1; then
+ fail "push-images.sh should require registry username in non-interactive mode"
+fi
+grep -q -- '--registry-username is required' /tmp/nexent-offline-package-push-missing-user.log || fail "push-images.sh missing username error should be explicit"
+
cat > "$latest_package_dir/load-images.sh" <<'SH'
#!/usr/bin/env bash
printf 'load-images\n' >> "$DEPLOY_WRAPPER_LOG"
SH
chmod +x "$latest_package_dir/load-images.sh"
+cat > "$latest_package_dir/push-images.sh" <<'SH'
+#!/usr/bin/env bash
+args=("$@")
+printf 'push:%s:%s\n' "${REGISTRY_PASSWORD:-}" "${args[*]}" >> "$DEPLOY_WRAPPER_LOG"
+SH
+chmod +x "$latest_package_dir/push-images.sh"
cat > "$latest_package_dir/deploy/deploy.sh" <<'SH'
#!/usr/bin/env bash
printf 'deploy:%s:%s\n' "${NEXENT_DEPLOY_CONFIG_MODE:-}" "$*" >> "$DEPLOY_WRAPPER_LOG"
@@ -219,11 +301,18 @@ second_line="$(sed -n '2p' "$offline_deploy_log")"
[ "$first_line" = "load-images" ] || fail "offline deploy.sh --load-images should load images before deploy"
[ "$second_line" = "deploy:defaults:docker --foo bar" ] || fail "offline deploy.sh --load-images should preserve defaults mode"
+: > "$offline_deploy_log"
+DEPLOY_WRAPPER_LOG="$offline_deploy_log" REGISTRY_USERNAME=user REGISTRY_PASSWORD=secret bash "$latest_package_dir/deploy.sh" --push-images --image-registry-prefix registry.local/nexent docker --foo bar
+first_line="$(sed -n '1p' "$offline_deploy_log")"
+second_line="$(sed -n '2p' "$offline_deploy_log")"
+[[ "$first_line" == "push:secret:--image-registry-prefix registry.local/nexent --load-images" ]] || fail "offline deploy.sh --push-images should push before deploy"
+[ "$second_line" = "deploy:defaults:docker --foo bar --image-registry-prefix registry.local/nexent" ] || fail "offline deploy.sh --push-images should preserve defaults mode and forward registry prefix"
+
[ -f "$OUT_DIR/nexent-offline-docker-amd64-latest.zip" ] || fail "zip package should be created for latest package"
grep -q "nexent/nexent:latest" "$latest_package_dir/manifest.yaml" || fail "manifest should include local latest Nexent image"
-! grep -q '^pull .*nexent/nexent:latest$' "$latest_pull_log" || fail "latest Nexent image should not be pulled"
-! grep -q '^pull .*nexent/nexent-web:latest$' "$latest_pull_log" || fail "latest Nexent web image should not be pulled"
-! grep -q '^pull .*nexent/nexent-mcp:latest$' "$latest_pull_log" || fail "latest Nexent MCP image should not be pulled"
+grep -q '^pull .*nexent/nexent:latest$' "$latest_pull_log" || fail "latest Nexent image should be pulled"
+grep -q '^pull .*nexent/nexent-web:latest$' "$latest_pull_log" || fail "latest Nexent web image should be pulled"
+grep -q '^pull .*nexent/nexent-mcp:latest$' "$latest_pull_log" || fail "latest Nexent MCP image should be pulled"
grep -q '^pull .*docker.elastic.co/elasticsearch/elasticsearch:8.17.4$' "$latest_pull_log" || fail "non-latest infrastructure images should still be pulled"
local_package_dir="$OUT_DIR/local-existing/package"
@@ -243,6 +332,7 @@ PATH="$BIN_DIR:$PATH" \
--output-dir "$local_package_dir" >/tmp/nexent-offline-package-local-existing.log
assert_common_package_files "$local_package_dir"
+[ "$(cat "$local_package_dir/VERSION")" = "v2.2.0" ] || fail "root VERSION should match requested package version"
[ -f "$OUT_DIR/local-existing/nexent-offline-docker-amd64-v2.2.0.zip" ] || fail "zip package should be created for local existing package"
! grep -q '^pull .*nexent/nexent:v2.2.0$' "$local_pull_log" || fail "existing local Nexent image should not be pulled"
! grep -q '^pull .*docker.elastic.co/elasticsearch/elasticsearch:8.17.4$' "$local_pull_log" || fail "existing local infrastructure image should not be pulled"
diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh
index c3267d499..6798de5bf 100755
--- a/deploy/tests/test_common.sh
+++ b/deploy/tests/test_common.sh
@@ -161,6 +161,27 @@ PRODUCTION_HELM_CONTENT="$(cat "$PRODUCTION_HELM_VALUES")"
assert_contains "$PRODUCTION_HELM_CONTENT" $'services:\n northbound:\n type: "NodePort"\n nodePort: 30013' "production k8s should expose northbound as NodePort"
assert_contains "$PRODUCTION_HELM_CONTENT" $'services:\n web:\n type: "NodePort"\n nodePort: 30000' "production k8s should expose web as NodePort"
+unset NEXENT_IMAGE NEXENT_WEB_IMAGE NEXENT_DATA_PROCESS_IMAGE NEXENT_MCP_DOCKER_IMAGE
+unset ELASTICSEARCH_IMAGE POSTGRESQL_IMAGE REDIS_IMAGE MINIO_IMAGE OPENSSH_SERVER_IMAGE
+unset SUPABASE_KONG SUPABASE_GOTRUE SUPABASE_DB
+deployment_prepare_config --components infrastructure,application --port-policy development --image-source local-latest --image-registry-prefix https://registry.local/nexent/ --app-version latest
+assert_eq "registry.local/nexent" "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" "image registry prefix should be normalized"
+deployment_apply_image_source
+assert_eq "registry.local/nexent/nexent/nexent:latest" "$NEXENT_IMAGE" "image registry prefix should be applied to local latest backend image"
+PREFIXED_DOCKER_ENV="$TMP_DIR/prefixed-docker.env"
+deployment_render_docker_env "$PREFIXED_DOCKER_ENV"
+assert_contains "$(cat "$PREFIXED_DOCKER_ENV")" 'NEXENT_IMAGE_REGISTRY_PREFIX="registry.local/nexent/"' "docker env should expose registry prefix for monitoring compose images"
+PREFIXED_HELM_VALUES="$TMP_DIR/prefixed-generated-values.yaml"
+deployment_render_helm_values "$PREFIXED_HELM_VALUES"
+PREFIXED_HELM_CONTENT="$(cat "$PREFIXED_HELM_VALUES")"
+assert_contains "$PREFIXED_HELM_CONTENT" 'imageRegistryPrefix: "registry.local/nexent"' "helm values should record registry prefix"
+assert_contains "$PREFIXED_HELM_CONTENT" 'repository: "registry.local/nexent/nexent/nexent"' "helm values should use prefixed app image repositories"
+assert_contains "$PREFIXED_HELM_CONTENT" $' initImage:\n repository: "registry.local/nexent/postgres"\n tag: "15-alpine"\n pullPolicy: "IfNotPresent"' "helm values should use prefixed supabase auth init image"
+assert_contains "$PREFIXED_HELM_CONTENT" 'pullPolicy: "IfNotPresent"' "prefixed local-latest images should be pulled from the registry"
+unset NEXENT_IMAGE NEXENT_WEB_IMAGE NEXENT_DATA_PROCESS_IMAGE NEXENT_MCP_DOCKER_IMAGE
+unset ELASTICSEARCH_IMAGE POSTGRESQL_IMAGE REDIS_IMAGE MINIO_IMAGE OPENSSH_SERVER_IMAGE
+unset SUPABASE_KONG SUPABASE_GOTRUE SUPABASE_DB
+
deployment_prepare_config --components supabase --port-policy development --app-version latest
assert_eq "infrastructure,supabase" "$DEPLOYMENT_COMPONENTS" "only infrastructure should be required and added"
if [[ "$DEPLOYMENT_SELECTED_DOCKER_SERVICES" == *"nexent-web"* ]]; then
@@ -355,6 +376,8 @@ assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-openssh/templates/deploymen
assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-openssh/templates/deployment.yaml")" "checksum/nexent-env" "openssh deployment should include env rollout annotation"
assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-minio/templates/deployment.yaml")" "checksum/nexent-env" "minio deployment should include env rollout annotation"
assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-supabase-auth/templates/deployment.yaml")" "checksum/nexent-supabase-secret" "supabase auth deployment should include supabase secret rollout annotation"
+assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-supabase-auth/templates/deployment.yaml")" ".Values.initImage.repository" "supabase auth init container should use configurable image repository"
+assert_not_contains "$(cat "$K8S_CHART_DIR/charts/nexent-supabase-auth/templates/deployment.yaml")" "image: postgres:15-alpine" "supabase auth init container should not hardcode postgres image"
assert_not_contains "$(cat "$K8S_CHART_DIR/charts/nexent-supabase-auth/templates/deployment.yaml")" "checksum/nexent-env" "supabase auth deployment should not use full env rollout annotation"
assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-supabase-db/templates/deployment.yaml")" "checksum/nexent-supabase-secret" "supabase db deployment should include supabase secret rollout annotation"
assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-supabase-db/templates/deployment.yaml")" "checksum/nexent-sql" "supabase db deployment should keep SQL rollout annotation"
@@ -435,11 +458,20 @@ assert_contains "$MONITORING_HELM_CONTENT" 'configFile: "otel-collector-langsmit
deployment_update_env_var_file "$MONITORING_ENV_TMP" "LANGFUSE_INIT_PROJECT_PUBLIC_KEY" "pk-test"
deployment_update_env_var_file "$MONITORING_ENV_TMP" "LANGFUSE_INIT_PROJECT_SECRET_KEY" "sk-test"
deployment_update_env_var_file "$MONITORING_ENV_TMP" "LANGFUSE_OTLP_AUTH_HEADER" "Basic stale"
+deployment_update_env_var_file "$MONITORING_ENV_TMP" "MONITORING_DASHBOARD_URL" ""
deployment_prepare_config --components infrastructure,application,monitoring --monitoring-provider langfuse --app-version latest
deployment_prepare_monitoring_env docker
EXPECTED_LANGFUSE_AUTH_HEADER="Basic $(printf "%s:%s" "pk-test" "sk-test" | base64 | tr -d '\n')"
assert_eq "$EXPECTED_LANGFUSE_AUTH_HEADER" "$(deployment_get_env_var_file "$MONITORING_ENV_TMP" "LANGFUSE_OTLP_AUTH_HEADER")" "monitoring.env should refresh derived Langfuse OTLP auth header"
assert_eq "../assets/monitoring/otel-collector-langfuse-config.yml" "$(deployment_get_env_var_file "$MONITORING_ENV_TMP" "OTEL_COLLECTOR_CONFIG_FILE")" "monitoring.env should record Docker collector config file"
+assert_eq "http://localhost:3001" "$(deployment_get_env_var_file "$MONITORING_ENV_TMP" "MONITORING_DASHBOARD_URL")" "empty monitoring dashboard URL should use the selected provider default"
+deployment_update_env_var_file "$MONITORING_ENV_TMP" "MONITORING_DASHBOARD_URL" "https://monitor.example.com/grafana"
+deployment_prepare_config --components infrastructure,application,monitoring --monitoring-provider grafana --app-version latest
+deployment_prepare_monitoring_env docker
+assert_eq "https://monitor.example.com/grafana" "$(deployment_get_env_var_file "$MONITORING_ENV_TMP" "MONITORING_DASHBOARD_URL")" "explicit monitoring dashboard URL should be preserved"
+deployment_prepare_config --components infrastructure,application --monitoring-provider grafana --app-version latest
+deployment_prepare_monitoring_env docker
+assert_eq "" "$(deployment_get_env_var_file "$MONITORING_ENV_TMP" "MONITORING_DASHBOARD_URL")" "disabled monitoring should clear dashboard URL"
while IFS='=' read -r key _; do
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
unset "$key"
@@ -629,6 +661,7 @@ assert_contains "$(cat "$MONITORING_EXAMPLE_FILE")" "LANGFUSE_CLICKHOUSE_CLUSTER
assert_contains "$(cat "$SCRIPT_DIR/../docker/compose/docker-compose-monitoring.yml")" 'CLICKHOUSE_CLUSTER_ENABLED: ${LANGFUSE_CLICKHOUSE_CLUSTER_ENABLED:-false}' "docker compose Langfuse clickhouse cluster fallback should match monitoring.env.example"
LOCAL_CONFIG="$TMP_DIR/local-config.yaml"
+DEPLOYMENT_IMAGE_REGISTRY_PREFIX="registry.local/nexent"
deployment_persist_local_config "$LOCAL_CONFIG"
if grep -Eq 'PASSWORD|TOKEN|JWT|SECRET|KEY' "$LOCAL_CONFIG"; then
echo "FAIL: persisted local config should not contain secret-looking fields"
@@ -642,6 +675,19 @@ if grep -q 'appVersion' "$LOCAL_CONFIG"; then
echo "FAIL: persisted local config should not contain appVersion"
exit 1
fi
+assert_contains "$(cat "$LOCAL_CONFIG")" 'imageRegistryPrefix: "registry.local/nexent"' "persisted local config should include image registry prefix"
+
+K8S_DEPLOY_OPTIONS_BLOCK="$(awk '/persist_deploy_options\(\) {/,/^}/' "$SCRIPT_DIR/../k8s/deploy.sh")"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "PERSISTENCE_MODE=\"${PERSISTENCE_MODE}\""' "k8s deploy options should persist persistence mode"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "STORAGE_CLASS_NAME=\"${STORAGE_CLASS_NAME}\""' "k8s deploy options should persist storage class"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "LOCAL_PATH=\"${LOCAL_PATH}\""' "k8s deploy options should persist local path"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "EXISTING_CLAIM_PREFIX=\"${EXISTING_CLAIM_PREFIX}\""' "k8s deploy options should persist existing claim prefix"
+assert_not_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'LOCAL_NODE_NAME=' "k8s deploy options should not persist deprecated local node name"
+assert_not_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'K8S_WAIT_TIMEOUT_SECONDS=' "k8s deploy options should not persist one-time wait timeout"
+if echo "$K8S_DEPLOY_OPTIONS_BLOCK" | grep -Eq 'PASSWORD|TOKEN|JWT|SECRET|KEY'; then
+ echo "FAIL: k8s deploy options should not persist secret-looking fields"
+ exit 1
+fi
assert_success "b should be treated as TUI back key" deployment_tui_is_back_key "b"
assert_success "Backspace should be treated as TUI back key" deployment_tui_is_back_key $'\177'
diff --git a/doc/docs/en/deployment/docker-build.md b/doc/docs/en/deployment/docker-build.md
index 0a85c9395..a2bbe92cd 100644
--- a/doc/docs/en/deployment/docker-build.md
+++ b/doc/docs/en/deployment/docker-build.md
@@ -263,3 +263,5 @@ bash deploy.sh --load-images docker \
--components infrastructure,application,data-process,supabase \
--image-source local-latest
```
+
+To push the packaged images to an internal registry during offline deployment, replace `--load-images` with `--push-images --image-registry-prefix registry.example.com/nexent`. If the prefix is omitted, the wrapper prompts for it before `push-images.sh` asks for the registry username and password. The deployment config will use the same registry prefix for Docker Compose image references.
diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md
index ab17be7e9..9c69c57a4 100644
--- a/doc/docs/en/quick-start/installation.md
+++ b/doc/docs/en/quick-start/installation.md
@@ -190,7 +190,7 @@ bash deploy/offline/build_offline_package.sh \
--output-dir offline-package
```
-The package directory contains `images/*.tar`, `load-images.sh`, `deploy.sh`, `uninstall.sh`, `manifest.yaml`, `checksums.txt`, `deploy/env/.env.example`, `deploy/env/monitoring.env.example`, and `deploy/sql`. It does not include local `deploy/env/.env`, `deploy/env/monitoring.env`, or `deploy.options`. With `--compress true`, a `nexent-offline---.zip` archive is created next to the output directory.
+The package directory contains `images/*.tar`, `load-images.sh`, `push-images.sh`, `deploy.sh`, `uninstall.sh`, `manifest.yaml`, `checksums.txt`, `deploy/env/.env.example`, `deploy/env/monitoring.env.example`, and `deploy/sql`. It does not include local `deploy/env/.env`, `deploy/env/monitoring.env`, or `deploy.options`. With `--compress true`, a `nexent-offline---.zip` archive is created next to the output directory.
On the target host, the package root `deploy.sh` uses saved `deploy.options` when present, otherwise built-in defaults, and does not open the TUI by default. Add `--config` to open the interactive configuration UI. If the package was built with a custom version, component set, port policy, or image source, pass the same options during deployment or use `--config` to select them interactively:
@@ -199,6 +199,14 @@ cd offline-package
bash deploy.sh --load-images docker
```
+To push packaged images to an internal registry and deploy with that prefix:
+
+```bash
+bash deploy.sh --push-images --image-registry-prefix registry.example.com/nexent docker
+```
+
+When `--push-images` is used without a prefix, `deploy.sh` prompts for the image registry prefix first. `push-images.sh` then prompts for the registry username and password before pushing.
+
## 🔌 Port Mapping
| Service | Internal Port | External Port | Description |
diff --git a/doc/docs/en/quick-start/kubernetes-installation.md b/doc/docs/en/quick-start/kubernetes-installation.md
index 471c1857b..962a3db7c 100644
--- a/doc/docs/en/quick-start/kubernetes-installation.md
+++ b/doc/docs/en/quick-start/kubernetes-installation.md
@@ -202,7 +202,7 @@ bash deploy/offline/build_offline_package.sh \
--output-dir offline-package
```
-The package includes image tar files, `load-images.sh`, root deploy/uninstall entrypoints, Kubernetes Helm assets, SQL files, `deploy/env/.env.example`, `deploy/env/monitoring.env.example`, `manifest.yaml`, and `checksums.txt`. It does not include local `deploy/env/.env`, `deploy/env/monitoring.env`, or generated Helm values. With `--compress true`, a `nexent-offline---.zip` archive is created next to the output directory.
+The package includes image tar files, `load-images.sh`, `push-images.sh`, root deploy/uninstall entrypoints, Kubernetes Helm assets, SQL files, `deploy/env/.env.example`, `deploy/env/monitoring.env.example`, `manifest.yaml`, and `checksums.txt`. It does not include local `deploy/env/.env`, `deploy/env/monitoring.env`, or generated Helm values. With `--compress true`, a `nexent-offline---.zip` archive is created next to the output directory.
On the target host, the package root `deploy.sh` uses saved `deploy.options` when present, otherwise built-in defaults, and does not open the TUI by default. Add `--config` to open the interactive configuration UI. If the package was built with a custom version, component set, port policy, or image source, pass the same options during deployment or use `--config` to select them interactively. On a single-node Docker-backed cluster, you can load and deploy directly:
@@ -211,7 +211,13 @@ cd offline-package
bash deploy.sh --load-images k8s
```
-For multi-node clusters, load the images on every node that may run Nexent Pods, or push the loaded images to an internal registry and deploy with matching image settings.
+For multi-node clusters, load the images on every node that may run Nexent Pods, or push the packaged images to an internal registry and deploy with matching image settings:
+
+```bash
+bash deploy.sh --push-images --image-registry-prefix registry.example.com/nexent k8s
+```
+
+When `--push-images` is used without a prefix, `deploy.sh` prompts for the image registry prefix first. `push-images.sh` then prompts for the registry username and password before pushing.
## 🔧 Deployment Commands
diff --git a/doc/docs/zh/deployment/docker-build.md b/doc/docs/zh/deployment/docker-build.md
index c206cd7f0..6f2539e17 100644
--- a/doc/docs/zh/deployment/docker-build.md
+++ b/doc/docs/zh/deployment/docker-build.md
@@ -242,3 +242,5 @@ bash deploy.sh --load-images docker \
--components infrastructure,application,data-process,supabase \
--image-source local-latest
```
+
+如果离线部署时需要推送到内部镜像仓库,可将 `--load-images` 替换为 `--push-images --image-registry-prefix registry.example.com/nexent`。如果省略前缀,入口脚本会先询问镜像仓库前缀,随后 `push-images.sh` 询问仓库账号和密码。部署配置会使用同一个镜像仓库前缀生成 Docker Compose 镜像引用。
diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md
index 141575051..e59b58c00 100644
--- a/doc/docs/zh/quick-start/installation.md
+++ b/doc/docs/zh/quick-start/installation.md
@@ -186,7 +186,7 @@ bash deploy/offline/build_offline_package.sh \
--output-dir offline-package
```
-包目录会包含 `images/*.tar`、`load-images.sh`、`deploy.sh`、`uninstall.sh`、`manifest.yaml`、`checksums.txt`、`deploy/env/.env.example`、`deploy/env/monitoring.env.example` 和 `deploy/sql`,不会包含本地 `deploy/env/.env`、`deploy/env/monitoring.env` 或 `deploy.options`。使用 `--compress true` 时,会在输出目录的父目录生成 `nexent-offline---.zip`。
+包目录会包含 `images/*.tar`、`load-images.sh`、`push-images.sh`、`deploy.sh`、`uninstall.sh`、`manifest.yaml`、`checksums.txt`、`deploy/env/.env.example`、`deploy/env/monitoring.env.example` 和 `deploy/sql`,不会包含本地 `deploy/env/.env`、`deploy/env/monitoring.env` 或 `deploy.options`。使用 `--compress true` 时,会在输出目录的父目录生成 `nexent-offline---.zip`。
在目标机器上部署时,包根目录的 `deploy.sh` 会优先复用已保存的 `deploy.options`,否则使用内置默认值,默认不进入 TUI。添加 `--config` 可进入交互式配置界面。如果离线包构建时使用了自定义版本、组件、端口策略或镜像源,请在部署时传入相同选项,或使用 `--config` 交互选择:
@@ -195,6 +195,14 @@ cd offline-package
bash deploy.sh --load-images docker
```
+如果需要先推送到内部镜像仓库并使用该前缀部署:
+
+```bash
+bash deploy.sh --push-images --image-registry-prefix registry.example.com/nexent docker
+```
+
+启用 `--push-images` 且未传前缀时,`deploy.sh` 会先询问镜像仓库前缀;随后 `push-images.sh` 在推送前询问仓库账号和密码。
+
## 🔌 端口映射
| 服务 | 内部端口 | 外部端口 | 描述 |
diff --git a/doc/docs/zh/quick-start/kubernetes-installation.md b/doc/docs/zh/quick-start/kubernetes-installation.md
index 4e2fd4333..481af1ba7 100644
--- a/doc/docs/zh/quick-start/kubernetes-installation.md
+++ b/doc/docs/zh/quick-start/kubernetes-installation.md
@@ -202,7 +202,7 @@ bash deploy/offline/build_offline_package.sh \
--output-dir offline-package
```
-包内包含镜像 tar、`load-images.sh`、根目录部署/卸载入口、Kubernetes Helm 资源、SQL 文件、`deploy/env/.env.example`、`deploy/env/monitoring.env.example`、`manifest.yaml` 和 `checksums.txt`,不会包含本地 `deploy/env/.env`、`deploy/env/monitoring.env` 或生成的 Helm values。使用 `--compress true` 时,会在输出目录的父目录生成 `nexent-offline---.zip`。
+包内包含镜像 tar、`load-images.sh`、`push-images.sh`、根目录部署/卸载入口、Kubernetes Helm 资源、SQL 文件、`deploy/env/.env.example`、`deploy/env/monitoring.env.example`、`manifest.yaml` 和 `checksums.txt`,不会包含本地 `deploy/env/.env`、`deploy/env/monitoring.env` 或生成的 Helm values。使用 `--compress true` 时,会在输出目录的父目录生成 `nexent-offline---.zip`。
在目标机器上部署时,包根目录的 `deploy.sh` 会优先复用已保存的 `deploy.options`,否则使用内置默认值,默认不进入 TUI。添加 `--config` 可进入交互式配置界面。如果离线包构建时使用了自定义版本、组件、端口策略或镜像源,请在部署时传入相同选项,或使用 `--config` 交互选择。如果是单节点、Docker 作为容器运行时的集群,可以直接加载并部署:
@@ -211,7 +211,13 @@ cd offline-package
bash deploy.sh --load-images k8s
```
-多节点集群需要在每个可能运行 Nexent Pod 的节点上加载镜像,或将镜像推送到集群可访问的内部镜像仓库,再使用匹配的镜像参数部署。
+多节点集群需要在每个可能运行 Nexent Pod 的节点上加载镜像,或将镜像推送到集群可访问的内部镜像仓库,再使用匹配的镜像参数部署:
+
+```bash
+bash deploy.sh --push-images --image-registry-prefix registry.example.com/nexent k8s
+```
+
+启用 `--push-images` 且未传前缀时,`deploy.sh` 会先询问镜像仓库前缀;随后 `push-images.sh` 在推送前询问仓库账号和密码。
## 🔧 部署命令
From 592d0cb032c5c3c4d53c4b476cf4cd229191a885 Mon Sep 17 00:00:00 2001
From: Lifeng-Chen <174292121+Lifeng-Chen@users.noreply.github.com>
Date: Thu, 9 Jul 2026 19:50:32 +0800
Subject: [PATCH 13/26] feat(agent-repository): remove categories and allow
custom emoji icons (#3367)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Release/v2.2.1 (#3269)
* add_greeting_fields_to_agent-develop
* feat(knowledge-base): add preserve_source_file and post-index source cleanup
Let knowledge bases opt out of keeping uploaded MinIO copies after indexing
while retaining Elasticsearch chunks for retrieval. Default behavior remains
preserve_source_file=true for backward compatibility.
- Add preserve_source_file column (init.sql + v2.2.0_0601 migration)
- Accept preserve_source_file on create/update and northbound/vector APIs
- Support document DELETE scope=source_only and source_available in listings
- Run cleanup_source Celery task when preserve_source_file is false
- UI: create-KB toggle, list tag, knowledge-base preview when copy is missing
- Update vector-database SDK docs and backend tests
* test(data_process): stub knowledge_db, redis_service, and redis in test_worker
Align setup_mocks_for_worker with test_tasks so importing
backend.data_process.worker loads package __init__ without real DB/redis deps.
* test(data_process): shim cleanup_source for submit_process_forward_chain tests
* remove duplicate import
* fix: update unit tests for greeting_message and example_questions fields
* add init.sql to sonar.properites
* ♻️ Improvement: API to MCP conversion service supports configuring headers. (#3194)
* ♻️ Improvement: API to MCP conversion service supports configuring headers.
[Specification Details]
1. Front-end and back-end modifications
* ♻️ Improvement: API to MCP conversion service supports configuring headers.
[Specification Details]
1. Modify the frontend, after adding, set the HTTP headers to empty.
2. Modify test cases.
* ♻️ Improvement: Enhance processing of ES index names in memory banks. (#3196)
[Specification Details]
1. Replace all symbols in the index name that do not meet the rules with "_".
2. Modify test cases.
* feat: add active memory tools (StoreMemoryTool, SearchMemoryTool) (#3197)
- Implement StoreMemoryTool for explicit memory storage during agent reasoning
- Implement SearchMemoryTool for on-demand memory retrieval during conversations
- Integrate tools into agent creation flow (create_agent_info.py)
- Register tools in nexent_agent.py and tools/__init__.py
- Add MEMORY_OPERATION tool sign for proper categorization
- Fix memory_core.py cache key to include event loop ID (prevents cross-loop conflicts)
- Add comprehensive test coverage for both tools
- Add procedural memory verification documentation
Tools follow existing patterns: lazy imports, observer integration, error handling,
and respect user memory preferences (agent_share_option, disabled_agent_ids).
Co-authored-by: Dallas98 <40557804+Dallas98@users.noreply.github.com>
* 🐛 Bugfix: skill names and descriptions never load to context (#3205)
* 🐛 Bugfix: skill names and descriptions never load to context
* 🐛 Bugfix: skill names and descriptions never load to context
* 🐛 Bugfix: skill names and descriptions never load to context
* 🐛 Bugfix: official skills not copied to target directory
* 🐛 Bugfix: official skills not copied to target directory
* Feat: add selected count badges to tool/skill pool labels (#3206)
Co-authored-by: chase
* 🐛 Bugfix: Fix attribution error when tool calling error (#3208)
* ✨ Feat: Add support for Word document generation, preview, and download (#3191)
* Feat: Add support for Word document generation, preview, and download
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Restrict uploads to a known safe workspace/output directory
* 修改单元测试
* 修复单元测试
* Bugfix: Store uploaded files in Minio for conversation messages to enable file visibility in history
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* ✨Feat:Enhance prompt optimization by integrating openjiuwen and fix related bugs (#3190)
* ✨Feat:add prompt optimization
* 🐛Bugfix: dockerbuild failed when running pipefail in python3_11
* 🔨Optimize: Optimize prompt optimization display page and interaction methods
* 🐛Bugfix: fix dependencies replication
* 🎨:Optimize frontend prompts and loading interface
* 🔧 Refactor: Update imports and remove redundant ENABLE_JIUWEN_SDK import in prompt_service.py
* 🔧 Refactor: Correct import path for NexentCapabilityError and enhance test coverage for prompt optimization service
* 🔧 Refactor: Update import paths for exception handling and improve logging formatting in prompt_service.py
* 🔧 Refactor: Simplify lazy imports in jiuwen_sdk_adapter.py and update import paths in prompt_service.py
* 🔧 Refactor: Enhance Jiuwen SDK adapter handling and improve test stubs in prompt_service.py and related test files
* 🧪test:Pydantic model for PromptTemplateRequest in test_prompt_template_app.py
* 🔧 Refactor: Remove unnecessary dependency exclusions from pyproject.toml
* 🔧 Update: Upgrade huggingface_hub dependency version in pyproject.toml
* 🔧 Update: Exclude unnecessary transitive dependencies and adjust huggingface_hub version in pyproject.toml
* 🔧 Test: Add mock modules for unstructured inference and set up package paths in test files
* 🔧 Test: Enhance test setup by adding optional SDK mocks and cleaning up module imports in data processing tests
* 🔧 Test: Consolidate mock module setup for unstructured inference across multiple test files
* 🔧 Test: Remove unused optional SDK mocks from test configuration
* 🔧 Refactor: Clean up imports and enhance dynamic loading of fastmcp components in Docker client
* 📦update:sdk dependence update
* Add CAS SSO integration and improve logout handling (#3072)
* feat: add CAS SSO integration
* Skip CAS logout when CAS_LOGOUT_URL is unset
* 取消转义
* Improve CAS logout handling and confirm user logout
* Disable account deletion for CAS users
* Add CAS session init SQL and k8s config
* clean code
* Remove agent guardrails design doc from tracking
* 补充文档
---------
Co-authored-by: hhhhsc
* 🐛Bugfix: Remove unnecessary dependency exclusions and upgrade huggingface_hub version in pyproject.toml (#3211)
* refactor: move current time from system prompt to user message for prompt cache stability (#3203)
Remove {{time}} from all 4 prompt YAML templates (manager/managed × en/zh)
and strip time_str from the context_utils pipeline (_format_app_context,
build_skeleton_header_component, build_context_components,
build_app_context_string). Also remove time from create_agent_info render
kwargs and build_context_components call.
In CoreAgent.run, prepend [Current time: ...] to self.task so the timestamp
travels with the user message instead of being baked into the system prompt.
This makes the rendered system prompt fully deterministic per (agent_id,
tenant_id, version_no, language) — enabling prompt/KV cache hits across
requests for the same agent config.
Sync test_context_utils.py: drop time_str= from 3 test cases.
Remove unused datetime imports from context_utils.py and create_agent_info.py.
* 🐛 Bugfix: Fixed the issue of being unable to add MCP services via containerization. (#3213)
[Specification Details]
1. Modify the DEFAULT_NETWORK_NAME when starting the MCP service in the container to match the name in docker-compose.
2. Modify the parameters passed to the add_mcp_service method; custom_headers defaults to None.
* 🐛 Bugfix: Fixed the issue where uploaded text files could not be parsed during a session. (#3219)
* 🐛 Bugfix: Fixed the issue where uploaded text files could not be parsed during a session.
[Specification Details]
1. The return parameter of the file_process method has changed and needs to be unpacked.
* 🐛 Bugfix: Fixed the issue where uploaded text files could not be parsed during a session.
[Specification Details]
1. Modify test case.
* 🐛 Bugfix: Fixed an issue where the MCP service could not be added correctly after updating the FastMCP version. (#3222)
[Specification Details]
1. Add `kwargs` to the `create_httpx_client` function to accept all additional parameters.
* 🐛 Bugfix: Fix incomplete display of tenant resources page after window resize (#3215)
* Move non-shadcn ui component to other folder
* Bugfix: Fix incomplete display of tenant resources page after window resize
* Bugfix: Fix incomplete display of tenant resources page after window resize
* Add agent marketplace repository and version pinning for sub-agents (#3239)
* feat: add agent marketplace repository and pin sub-agent versions at publish
Introduce ag_agent_repository_t with list/status/publish/import APIs for
frozen agent snapshots. Pin selected_agent_version_no on agent relations when
publishing so sub-agents resolve to a fixed version at runtime. Extend agent
export/import to bundle skills in ZIP payloads and add embedding model fallback
when no model name is provided.
* feat: add agent marketplace repository and pin sub-agent versions at publish
Introduce ag_agent_repository_t with list/status/publish/import APIs for
frozen agent snapshots. Pin selected_agent_version_no on agent relations when
publishing so sub-agents resolve to a fixed version at runtime. Extend agent
export/import to bundle skills in ZIP payloads and add embedding model fallback
when no model name is provided.
* feat: add agent marketplace repository and pin sub-agent versions at publish
Introduce ag_agent_repository_t with list/status/publish/import APIs for
frozen agent snapshots. Pin selected_agent_version_no on agent relations when
publishing so sub-agents resolve to a fixed version at runtime. Extend agent
export/import to bundle skills in ZIP payloads and add embedding model fallback
when no model name is provided.
* feat: add agent marketplace repository and pin sub-agent versions at publish
Introduce ag_agent_repository_t with list/status/publish/import APIs for
frozen agent snapshots. Pin selected_agent_version_no on agent relations when
publishing so sub-agents resolve to a fixed version at runtime. Extend agent
export/import to bundle skills in ZIP payloads and add embedding model fallback
when no model name is provided.
* feat(agent): add verification configuration for agents and update related components (#3174)
* feat(agent): add verification configuration for agents and update related components
* feat(model): update model type labels and add monitoring dashboard translations
* 🐛 Bugfix: Fix inability to select agent from agent space to edit (#3240)
* Move non-shadcn ui component to other folder
* Bugfix: Fix incomplete display of tenant resources page after window resize
* Bugfix: Fix incomplete display of tenant resources page after window resize
* Bugfix: Fix inability to select agent from agent space to edit
* Bugfix: Display correct version info when viewing agent details
* Update data agent and ME CAS integration documentation (#3242)
* 补充dataagent对接文档
* 补充ME cas对接文档
* 补充ME cas对接文档
---------
Co-authored-by: hhhhsc
* ✨ Add several northbound apis (#3223)
* ✨ Add several northbound apis
* ✨ Add several northbound apis
* ✨ Add several northbound apis
* ✨ Add several northbound apis
* ✨ Add several northbound apis
* refactor: simplify deployment script by removing unused variables and functions (#3245)
* feat(agent): add verification configuration for agents and update related components
* feat(model): update model type labels and add monitoring dashboard translations
* refactor(build_offline_package): simplify deployment script by removing unused variables and functions
* 🐛 Bugfix: Adjust agent detail UI layout to accommodate newly added "self-verification" field (#3246)
* Move non-shadcn ui component to other folder
* Bugfix: Fix incomplete display of tenant resources page after window resize
* Bugfix: Fix incomplete display of tenant resources page after window resize
* Bugfix: Fix inability to select agent from agent space to edit
* Bugfix: Display correct version info when viewing agent details
* Bugfix: Adjust agent detail UI layout to accommodate newly added "self-verification" field
* 补充sql (#3248)
* 补充sql
* 扩大limit限制
* 🐛 Bugfix: Fixed an issue where the MCP service failed to start in a Kubernetes container. (#3254)
[Specification Details]
1. Modify the pod naming logic to convert all non-compliant characters to -.
2. Modify test cases.
* 🐛 Bugfix: knowledge_base_search_tool called with TypeError: argument of type 'FieldInfo' is not iterable (#3259)
* 🐛 Bugfix: Fixed an issue where the one-click rename function failed after importing an agent. (#3258)
[Specification Details]
1. The frontend does not pass `agent_id` when calling the `regenerate_name` API.
* Bugfix: Exclude attachments from assistant when saving conversation history (#3261)
* Bump APP_VERSION from v2.2.0 to v2.2.1 (#3268)
The default setting for client-side self-validation is "False".
---------
Co-authored-by: chase
Co-authored-by: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com>
Co-authored-by: Dallas98 <40557804+Dallas98@users.noreply.github.com>
Co-authored-by: Jason Wang <56037774+JasonW404@users.noreply.github.com>
Co-authored-by: Xia Yichen
Co-authored-by: JeffWu <45140512+jeffwu-1999@users.noreply.github.com>
Co-authored-by: WMC001 <46217886+WMC001@users.noreply.github.com>
Co-authored-by: xuyaqi
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: DongJiBao2001 <120021235+DongJiBao2001@users.noreply.github.com>
Co-authored-by: hhhhsc701 <56435672+hhhhsc701@users.noreply.github.com>
Co-authored-by: Dallas98 <990259227@qq.com>
Co-authored-by: frr <64584192+wuyuanfr@users.noreply.github.com>
* Revert "Release/v2.2.1 (#3269)" (#3272)
This reverts commit 9ff420ecce6b2ca21a67ce51053205860a76e41a.
* feat(agent-repository): remove categories and allow custom emoji icons
* feat(agent-space): improve mine tab UX with delete, navigation, and author display
- Add delete action for agents in mine tab with confirmation modal
- Navigate back to agent repository when editing from agent-space
- Persist agent-space tab state via URL query params (?tab=mine)
- Display author in repository detail modal and map author field
- Auto-fill agent author from user email on create
- Adjust agents page header layout to accommodate back button
* fix conflict
* fix conflict
* fix: delete test ts file
---------
Co-authored-by: panyehong <91180085+YehongPan@users.noreply.github.com>
Co-authored-by: chase
Co-authored-by: Dallas98 <40557804+Dallas98@users.noreply.github.com>
Co-authored-by: Jason Wang <56037774+JasonW404@users.noreply.github.com>
Co-authored-by: Xia Yichen
Co-authored-by: JeffWu <45140512+jeffwu-1999@users.noreply.github.com>
Co-authored-by: WMC001 <46217886+WMC001@users.noreply.github.com>
Co-authored-by: xuyaqi
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: DongJiBao2001 <120021235+DongJiBao2001@users.noreply.github.com>
Co-authored-by: hhhhsc701 <56435672+hhhhsc701@users.noreply.github.com>
Co-authored-by: Dallas98 <990259227@qq.com>
Co-authored-by: frr <64584192+wuyuanfr@users.noreply.github.com>
---
backend/apps/agent_repository_app.py | 5 -
backend/consts/model.py | 8 -
backend/database/agent_repository_db.py | 7 -
backend/database/db_models.py | 1 -
backend/services/agent_repository_service.py | 16 +-
.../sql/migrations/v2.2_merged_migrations.sql | 2 -
.../components/AgentRepositoryCard.tsx | 16 +-
.../components/AgentRepositoryDetailModal.tsx | 51 +-
.../agent-space/components/MineAgentsView.tsx | 46 +-
.../components/MineApplyListingModal.tsx | 191 +++---
.../agent-space/components/MyAgentCard.tsx | 20 +-
frontend/app/[locale]/agent-space/page.tsx | 89 +--
.../agents/components/AgentSelectorHeader.tsx | 30 +-
.../agentConfig/SkillBuildModal.tsx | 14 +-
.../agentInfo/AgentGenerateDetail.tsx | 78 +--
.../agentInfo/PromptTemplateManagerModal.tsx | 14 +-
frontend/app/[locale]/agents/page.tsx | 7 +-
frontend/const/agentRepository.ts | 23 +-
frontend/lib/agentRepositoryDetail.test.ts | 60 --
frontend/lib/agentRepositoryDetail.ts | 3 +
frontend/lib/agentRepositoryIcon.ts | 27 +
frontend/lib/agentRepositoryLabels.test.ts | 47 --
frontend/lib/agentRepositoryLabels.ts | 96 +--
frontend/lib/agentRepositoryMine.test.ts | 555 ------------------
frontend/lib/agentRepositoryMine.ts | 20 +-
frontend/public/locales/en/common.json | 11 +-
frontend/public/locales/zh/common.json | 11 +-
frontend/services/api.ts | 3 -
frontend/types/agentRepository.ts | 10 -
test/backend/app/test_agent_repository_app.py | 6 -
.../database/test_agent_repository_db.py | 7 +-
.../services/test_agent_repository_service.py | 27 +-
32 files changed, 409 insertions(+), 1092 deletions(-)
delete mode 100644 frontend/lib/agentRepositoryDetail.test.ts
create mode 100644 frontend/lib/agentRepositoryIcon.ts
delete mode 100644 frontend/lib/agentRepositoryLabels.test.ts
delete mode 100644 frontend/lib/agentRepositoryMine.test.ts
diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py
index df96e576c..f7f063282 100644
--- a/backend/apps/agent_repository_app.py
+++ b/backend/apps/agent_repository_app.py
@@ -26,10 +26,6 @@
async def list_agent_repository_listings_api(
status: Optional[str] = Query(None, description="Filter by listing status"),
agent_id: Optional[int] = Query(None, description="Filter by source agent ID"),
- category_id: Optional[int] = Query(
- None,
- description="Filter by marketplace category ID",
- ),
page: Annotated[int, Query(ge=1, description="Page number starting from 1")] = 1,
page_size: Annotated[
int, Query(ge=1, le=100, description="Page size from 1 to 100")
@@ -46,7 +42,6 @@ async def list_agent_repository_listings_api(
tenant_id,
status=status,
agent_id=agent_id,
- category_id=category_id,
page=page,
page_size=page_size,
search=search,
diff --git a/backend/consts/model.py b/backend/consts/model.py
index bdcde905e..9d93e58b5 100644
--- a/backend/consts/model.py
+++ b/backend/consts/model.py
@@ -717,19 +717,11 @@ class AgentRepositoryListingCreateRequest(BaseModel):
icon: Optional[str] = Field(None, description="Marketplace card icon (emoji or URL)")
downloads: int = Field(0, ge=0, description="Initial download/copy count for card display")
tags: Optional[List[str]] = Field(None, description="Marketplace tags")
- category_id: Optional[int] = Field(0, description="Optional marketplace category ID")
tool_count: Optional[int] = Field(
None, ge=0, description="Total tool count across all agents in the bundle"
)
-class AgentRepositoryCategoryItem(BaseModel):
- """Marketplace category option for agent repository filtering."""
- id: int
- key: str
- name: str
-
-
class AgentRepositoryListingDetailResponse(BaseModel):
"""Detailed marketplace listing payload for repository detail view."""
agent_repository_id: int
diff --git a/backend/database/agent_repository_db.py b/backend/database/agent_repository_db.py
index fe6813188..0937d69df 100644
--- a/backend/database/agent_repository_db.py
+++ b/backend/database/agent_repository_db.py
@@ -28,7 +28,6 @@
"display_name",
"description",
"author",
- "category_id",
"tags",
"tool_count",
"version_name",
@@ -180,7 +179,6 @@ def list_agent_repository_summaries(
*,
status: Optional[str] = None,
agent_id: Optional[int] = None,
- category_id: Optional[int] = None,
) -> List[dict]:
"""List active repository summaries for a publisher tenant without heavy JSON blobs."""
with get_db_session() as session:
@@ -193,7 +191,6 @@ def list_agent_repository_summaries(
AgentRepository.display_name,
AgentRepository.description,
AgentRepository.status,
- AgentRepository.category_id,
AgentRepository.tags,
AgentRepository.tool_count,
AgentRepository.version_name,
@@ -207,8 +204,6 @@ def list_agent_repository_summaries(
query = query.filter(AgentRepository.status == status)
if agent_id is not None:
query = query.filter(AgentRepository.agent_id == agent_id)
- if category_id is not None:
- query = query.filter(AgentRepository.category_id == category_id)
rows = query.order_by(AgentRepository.agent_repository_id.desc()).all()
return [
{
@@ -220,7 +215,6 @@ def list_agent_repository_summaries(
"display_name": row.display_name,
"description": row.description,
"status": row.status,
- "category_id": row.category_id,
"tags": row.tags,
"tool_count": row.tool_count,
"version_name": row.version_name,
@@ -244,7 +238,6 @@ def update_agent_repository_by_id(
"description",
"author",
"submitted_by",
- "category_id",
"tags",
"tool_count",
"version_name",
diff --git a/backend/database/db_models.py b/backend/database/db_models.py
index b0d6d710f..4f4b2f553 100644
--- a/backend/database/db_models.py
+++ b/backend/database/db_models.py
@@ -871,7 +871,6 @@ class AgentRepository(TableBase):
description = Column(Text, doc="Root agent description")
author = Column(String(100), doc="Agent author")
submitted_by = Column(String(100), doc="Submitter email when listing enters pending_review")
- category_id = Column(Integer, doc="Optional marketplace category ID")
tags = Column(ARRAY(Text), doc="Marketplace tags")
tool_count = Column(Integer,
doc="Total tool count across all agents in the bundle (display only)")
diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py
index 84099ae99..9ead66438 100644
--- a/backend/services/agent_repository_service.py
+++ b/backend/services/agent_repository_service.py
@@ -91,7 +91,6 @@ def _to_summary_item(
"display_name": record.get("display_name"),
"description": record.get("description"),
"status": record.get("status"),
- "category_id": record.get("category_id"),
"tags": record.get("tags") or [],
"tool_count": record.get("tool_count") or 0,
"version_label": record.get("version_name"),
@@ -119,13 +118,11 @@ def _matches_repository_listing_search_filter(record: dict, search: str) -> bool
return True
name = str(record.get("display_name") or record.get("name") or "").lower()
description = str(record.get("description") or "").lower()
- author = str(record.get("author") or "").lower()
tags = record.get("tags") or []
tag_text = " ".join(str(tag).lower() for tag in tags if isinstance(tag, str))
return (
query in name
or query in description
- or query in author
or query in tag_text
)
@@ -135,7 +132,6 @@ def list_agent_repository_listings_impl(
*,
status: Optional[str] = None,
agent_id: Optional[int] = None,
- category_id: Optional[int] = None,
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
@@ -150,7 +146,6 @@ def list_agent_repository_listings_impl(
publisher_tenant_id=tenant_id,
status=status,
agent_id=agent_id,
- category_id=category_id,
)
if search and search.strip():
records = [
@@ -225,10 +220,6 @@ def _validate_card_fields(repository_data: Dict[str, Any]) -> None:
f"icon must be at most {_MAX_LISTING_ICON_LENGTH} characters"
)
- category_id = repository_data.get("category_id")
- if category_id is None or not isinstance(category_id, int):
- raise ValueError("category_id is required and must be an integer")
-
tags = repository_data.get("tags")
if tags is None:
raise ValueError("tags is required for marketplace listing submission")
@@ -728,7 +719,6 @@ def _to_list_item(record: Dict[str, Any]) -> Dict[str, Any]:
"description": record.get("description"),
"author": record.get("author"),
"submitted_by": record.get("submitted_by"),
- "category_id": record.get("category_id"),
"tags": record.get("tags") or [],
"tool_count": record.get("tool_count"),
"version_label": record.get("version_name"),
@@ -850,7 +840,7 @@ async def _build_repository_data_from_agent(
}
if card_fields:
- for key in ("icon", "downloads", "category_id", "tool_count"):
+ for key in ("icon", "downloads", "tool_count"):
if key in card_fields and card_fields[key] is not None:
repository_data[key] = card_fields[key]
if "tags" in card_fields and card_fields["tags"] is not None:
@@ -873,7 +863,7 @@ async def create_agent_repository_listing_impl(
then inserts or updates the marketplace table.
When a listing for the same agent version already exists, its status is
- updated to pending_review along with icon, tags, and category when provided.
+ updated to pending_review along with icon and tags when provided.
"""
if version_no < 0:
raise ValueError("version_no must be >= 0")
@@ -902,7 +892,7 @@ async def create_agent_repository_listing_impl(
else:
repository_id = int(existing["agent_repository_id"])
updates: Dict[str, Any] = {"status": STATUS_PENDING_REVIEW}
- for key in ("icon", "tags", "category_id", "tool_count"):
+ for key in ("icon", "tags", "tool_count"):
if key in repository_data:
updates[key] = repository_data[key]
affected = update_agent_repository_by_id(
diff --git a/deploy/sql/migrations/v2.2_merged_migrations.sql b/deploy/sql/migrations/v2.2_merged_migrations.sql
index 2c134da51..c1bd4b5be 100644
--- a/deploy/sql/migrations/v2.2_merged_migrations.sql
+++ b/deploy/sql/migrations/v2.2_merged_migrations.sql
@@ -346,7 +346,6 @@ CREATE TABLE IF NOT EXISTS nexent.ag_agent_repository_t (
description TEXT,
author VARCHAR(100),
submitted_by VARCHAR(100),
- category_id INTEGER,
tags TEXT[],
tool_count INTEGER,
icon VARCHAR(100),
@@ -408,7 +407,6 @@ COMMENT ON COLUMN nexent.ag_agent_repository_t.display_name IS 'Root agent displ
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.submitted_by IS 'Submitter email when listing enters pending_review';
-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_name IS 'Repository entry version name for display (from ag_tenant_agent_version_t)';
diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx
index 4dd96c155..1ea42459a 100644
--- a/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx
+++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx
@@ -4,12 +4,10 @@ import type { MenuProps } from "antd";
import { Button, Card, Dropdown } from "antd";
import { Bot, Copy, Download, Eye, MoreHorizontal, PackageX } from "lucide-react";
import { useTranslation } from "react-i18next";
-import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels";
import type { AgentRepositoryListingItem } from "@/types/agentRepository";
interface AgentRepositoryCardProps {
listing: AgentRepositoryListingItem;
- categoryName?: string | null;
showAdminMenu?: boolean;
isTakingDown?: boolean;
onCopyClick?: (listing: AgentRepositoryListingItem) => void;
@@ -19,7 +17,6 @@ interface AgentRepositoryCardProps {
export function AgentRepositoryCard({
listing,
- categoryName,
showAdminMenu = false,
isTakingDown = false,
onCopyClick,
@@ -31,9 +28,6 @@ export function AgentRepositoryCard({
const title =
listing.display_name?.trim() || listing.name?.trim() || t("agentRepository.card.untitled");
const author = listing.author?.trim();
- const category =
- categoryName?.trim() || t("agentRepository.review.unknownCategory");
- const subtitle = author ? `${author} · ${category}` : category;
const tags = listing.tags?.filter((tag) => tag.trim()) ?? [];
const toolCount = listing.tool_count ?? 0;
const versionText = listing.version_label;
@@ -79,9 +73,11 @@ export function AgentRepositoryCard({
{title}
-
- {subtitle}
-
+ {author ? (
+
+ {author}
+
+ ) : null}
{showMenu ? (
@@ -109,7 +105,7 @@ export function AgentRepositoryCard({
key={tag}
className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"
>
- {getAgentRepositoryTagLabel(tag, t)}
+ {tag}
))}
{toolCount > 0 ? (
diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx
index b62c944bc..3a76312a7 100644
--- a/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx
+++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx
@@ -8,6 +8,8 @@ import {
Clock,
Cpu,
Download,
+ Tag as TagIcon,
+ User,
Wrench,
XCircle,
} from "lucide-react";
@@ -133,25 +135,40 @@ function AgentRepositoryDetailMeta({
const { t } = useTranslation("common");
return (
-
- {detail.model_name ? (
+
+
+ {detail.model_name ? (
+
+
+ {detail.model_name}
+
+ ) : null}
+ {detail.version_label ? (
+
+
+ {detail.version_label}
+
+ ) : null}
-
- {detail.model_name}
-
- ) : null}
- {detail.version_label ? {detail.version_label} : null}
-
-
- {t("agentRepository.detail.downloads", {
- count: downloads.toLocaleString(),
- })}
-
- {createdAtText ? (
-
-
- {createdAtText}
+
+ {t("agentRepository.detail.downloads", {
+ count: downloads.toLocaleString(),
+ })}
+ {createdAtText ? (
+
+
+ {createdAtText}
+
+ ) : null}
+
+ {detail.author?.trim() ? (
+
+
+
+ {t("agentRepository.detail.author", { author: detail.author })}
+
+
) : null}
);
diff --git a/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx
index 9a5c3102d..25030b632 100644
--- a/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx
+++ b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx
@@ -2,11 +2,13 @@
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
-import { useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import { App, Button, Empty, Input, Spin } from "antd";
import { ChevronLeft, ChevronRight, Plus, Search, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import AgentImportWizard from "@/components/agent/AgentImportWizard";
+import { useConfirmModal } from "@/hooks/useConfirmModal";
+import { deleteAgent } from "@/services/agentConfigService";
import {
AGENTS_LIST_QUERY_KEY,
invalidateAgentRepositoryCaches,
@@ -81,6 +83,7 @@ export function MineAgentsView({
}: MineAgentsViewProps) {
const { t } = useTranslation("common");
const { message } = App.useApp();
+ const { confirm } = useConfirmModal();
const router = useRouter();
const queryClient = useQueryClient();
const params = useParams<{ locale: string }>();
@@ -103,6 +106,9 @@ export function MineAgentsView({
const createListingMutation = useCreateAgentRepositoryListing();
const updateStatusMutation = useUpdateAgentRepositoryStatus();
+ const deleteAgentMutation = useMutation({
+ mutationFn: (agentId: number) => deleteAgent(agentId),
+ });
const normalizedQuery = searchQuery.trim().toLowerCase();
@@ -133,7 +139,38 @@ export function MineAgentsView({
if (permission === "READ_ONLY") {
return;
}
- router.push(`/${locale}/agents?agent_id=${agentId}`);
+ router.push(
+ `/${locale}/agents?agent_id=${agentId}&from=agent-space&tab=mine`
+ );
+ };
+
+ const handleDeleteAgent = (agent: MyEditableAgentItem) => {
+ const name = agent.name?.trim() || t("agentRepository.card.untitled");
+ confirm({
+ title: t("businessLogic.config.modal.deleteTitle"),
+ content: t("businessLogic.config.modal.deleteContent", { name }),
+ onOk: async () => {
+ try {
+ const result = await deleteAgentMutation.mutateAsync(agent.agent_id);
+ if (!result.success) {
+ throw new Error(result.message || "delete failed");
+ }
+ message.success(
+ t("businessLogic.config.error.agentDeleteSuccess", { name })
+ );
+ await Promise.all([
+ invalidateAgentRepositoryCaches(queryClient),
+ queryClient.invalidateQueries({
+ queryKey: [AGENTS_LIST_QUERY_KEY],
+ }),
+ ]);
+ } catch (error) {
+ log.error("Failed to delete agent:", error);
+ message.error(t("businessLogic.config.error.agentDeleteFailed"));
+ throw error;
+ }
+ },
+ });
};
const handleEvaluate = (agent: MyEditableAgentItem) => {
@@ -361,11 +398,16 @@ export function MineAgentsView({
}
onApplyListing={() => handleApplyListing(agent)}
onViewReview={(mode) => handleViewReview(agent, mode)}
+ onDelete={() => handleDeleteAgent(agent)}
onEvaluate={() => handleEvaluate(agent)}
isApplying={
applyingAgentId === agent.agent_id &&
createListingMutation.isPending
}
+ isDeleting={
+ deleteAgentMutation.isPending &&
+ deleteAgentMutation.variables === agent.agent_id
+ }
/>
)
diff --git a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx
index 70a178251..7bc8156eb 100644
--- a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx
+++ b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx
@@ -1,19 +1,16 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
-import { App, Button, Modal, Select, Spin } from "antd";
-import { Share2 } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { App, Button, Dropdown, Input, Modal, Select, Spin } from "antd";
+import { ChevronDown, Share2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
- AGENT_REPOSITORY_CATEGORIES,
AGENT_REPOSITORY_ICONS,
AGENT_REPOSITORY_PRESET_TAGS,
} from "@/const/agentRepository";
import { useAgentRepositoryListings } from "@/hooks/agentRepository/useAgentRepositoryListings";
-import {
- getAgentRepositoryCategoryLabel,
- getAgentRepositoryTagLabel,
-} from "@/lib/agentRepositoryLabels";
+import { getAgentRepositoryTagLabel, resolveAgentRepositoryTagForSubmit } from "@/lib/agentRepositoryLabels";
+import { isSingleSimpleEmoji } from "@/lib/agentRepositoryIcon";
import {
buildApplyListingFormPrefill,
pickApplyListingPrefillSource,
@@ -25,6 +22,7 @@ import type {
const MAX_TAGS = 5;
const MAX_TAG_LENGTH = 20;
+const MAX_ICON_LENGTH = 32;
interface MineApplyListingModalProps {
open: boolean;
@@ -45,17 +43,12 @@ export function MineApplyListingModal({
const { message } = App.useApp();
const icons = AGENT_REPOSITORY_ICONS;
- const categories = AGENT_REPOSITORY_CATEGORIES;
const presetTags = AGENT_REPOSITORY_PRESET_TAGS;
- const allowedCategoryIds = useMemo(
- () => categories.map((category) => category.id),
- [categories]
- );
const [selectedIcon, setSelectedIcon] = useState(null);
- const [selectedCategoryId, setSelectedCategoryId] = useState(
- null
- );
+ const [iconInput, setIconInput] = useState("");
+ const [iconError, setIconError] = useState(null);
+ const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
const [selectedTags, setSelectedTags] = useState([]);
const agentId = agent?.agent_id;
@@ -79,17 +72,46 @@ export function MineApplyListingModal({
[presetTags, t]
);
+ const invalidIconMessage = t(
+ "agentRepository.mine.applyModal.validation.iconInvalid"
+ );
+
+ const applyIconInputFromValue = useCallback(
+ (value: string, showErrorWhenInvalid = true) => {
+ setIconInput(value);
+
+ const trimmedValue = value.trim();
+ if (!trimmedValue) {
+ setSelectedIcon(null);
+ setIconError(null);
+ return;
+ }
+
+ if (isSingleSimpleEmoji(trimmedValue)) {
+ setSelectedIcon(trimmedValue);
+ setIconError(null);
+ return;
+ }
+
+ setSelectedIcon(null);
+ setIconError(showErrorWhenInvalid ? invalidIconMessage : null);
+ },
+ [invalidIconMessage]
+ );
+
+ const clearIconState = useCallback(() => {
+ setIconInput("");
+ setSelectedIcon(null);
+ setIconError(null);
+ }, []);
+
useEffect(() => {
if (!open) {
return;
}
- const defaultIcon = icons[0] ?? null;
- const defaultCategoryId = categories[0]?.id ?? null;
-
if (!agent || !isListingsSuccess) {
- setSelectedIcon(defaultIcon);
- setSelectedCategoryId(defaultCategoryId);
+ clearIconState();
setSelectedTags([]);
return;
}
@@ -99,29 +121,30 @@ export function MineApplyListingModal({
agent.version_label
);
const prefill = buildApplyListingFormPrefill(source, {
- allowedIcons: icons,
- allowedCategoryIds,
maxTags: MAX_TAGS,
});
if (!prefill) {
- setSelectedIcon(defaultIcon);
- setSelectedCategoryId(defaultCategoryId);
+ clearIconState();
setSelectedTags([]);
return;
}
- setSelectedIcon(prefill.icon ?? defaultIcon);
- setSelectedCategoryId(prefill.categoryId ?? defaultCategoryId);
+ const trimmedIcon = prefill.icon?.trim();
+ if (trimmedIcon && isSingleSimpleEmoji(trimmedIcon)) {
+ applyIconInputFromValue(trimmedIcon, false);
+ } else {
+ clearIconState();
+ }
+
setSelectedTags(prefill.tags);
}, [
open,
agent,
isListingsSuccess,
listingsData,
- icons,
- categories,
- allowedCategoryIds,
+ clearIconState,
+ applyIconInputFromValue,
]);
const title =
@@ -141,17 +164,44 @@ export function MineApplyListingModal({
return normalized;
};
+ const handlePresetIconClick = (icon: string) => {
+ applyIconInputFromValue(icon, false);
+ setPresetDropdownOpen(false);
+ };
+
+ const presetDropdown = (
+
+
+ {icons.map((icon) => (
+ handlePresetIconClick(icon)}
+ className="flex size-10 items-center justify-center rounded-lg border border-slate-200 text-2xl transition-colors hover:border-primary hover:bg-primary/5 dark:border-slate-700 dark:hover:border-primary"
+ aria-label={icon}
+ >
+ {icon}
+
+ ))}
+
+
+ );
+
const handleSubmit = () => {
- if (!selectedIcon) {
- message.warning(t("agentRepository.mine.applyModal.validation.icon"));
+ if (iconInput.trim() && !isSingleSimpleEmoji(iconInput)) {
+ setIconError(invalidIconMessage);
+ message.warning(invalidIconMessage);
return;
}
- if (selectedCategoryId == null) {
- message.warning(t("agentRepository.mine.applyModal.validation.category"));
+
+ if (!selectedIcon) {
+ message.warning(t("agentRepository.mine.applyModal.validation.icon"));
return;
}
- const tags = normalizeTags(selectedTags);
+ const tags = normalizeTags(selectedTags).map((tag) =>
+ resolveAgentRepositoryTagForSubmit(tag, t)
+ );
if (tags.length === 0) {
message.warning(t("agentRepository.mine.applyModal.validation.tags"));
return;
@@ -175,7 +225,6 @@ export function MineApplyListingModal({
onSubmit({
icon: selectedIcon,
- category_id: selectedCategoryId,
tags,
});
};
@@ -213,45 +262,47 @@ export function MineApplyListingModal({
{t("agentRepository.mine.applyModal.icon")}
-
- {icons.map((icon) => {
- const isSelected = selectedIcon === icon;
- return (
+
+ applyIconInputFromValue(event.target.value)
+ }
+ maxLength={MAX_ICON_LENGTH}
+ status={iconError ? "error" : undefined}
+ className="w-[5.25rem] shrink-0 text-2xl"
+ styles={{
+ input: {
+ paddingInline: 2,
+ textAlign: "center",
+ },
+ }}
+ suffix={
+ presetDropdown}
+ >
setSelectedIcon(icon)}
- className={`flex size-11 items-center justify-center rounded-xl border text-2xl transition-colors ${
- isSelected
- ? "border-primary bg-primary/10 ring-2 ring-primary/30"
- : "border-slate-200 bg-slate-50 hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800"
- }`}
- aria-label={icon}
- aria-pressed={isSelected}
+ className="inline-flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
+ aria-label={t(
+ "agentRepository.mine.applyModal.iconPresetPicker"
+ )}
+ onClick={(event) => event.stopPropagation()}
>
- {icon}
+
- );
- })}
-
-
-
-
-
- {t("agentRepository.mine.applyModal.category")}
-
-
diff --git a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx
index 257459091..63ff1773b 100644
--- a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx
+++ b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx
@@ -11,6 +11,7 @@ import {
MoreHorizontal,
Pencil,
Share2,
+ Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -27,8 +28,10 @@ interface MyAgentCardProps {
onView: () => void;
onApplyListing: () => void;
onViewReview: (mode: "review" | "reviewUpdate") => void;
+ onDelete: () => void;
onEvaluate: () => void;
isApplying?: boolean;
+ isDeleting?: boolean;
}
const MENU_ACTION_I18N: Record = {
@@ -55,8 +58,10 @@ export function MyAgentCard({
onView,
onApplyListing,
onViewReview,
+ onDelete,
onEvaluate,
isApplying = false,
+ isDeleting = false,
}: MyAgentCardProps) {
const { t } = useTranslation("common");
@@ -98,6 +103,19 @@ export function MyAgentCard({
};
});
+ if (menuActions.length > 0) {
+ menuItems.push({ type: "divider" });
+ }
+
+ menuItems.push({
+ key: "delete",
+ danger: true,
+ icon: ,
+ label: t("agentRepository.mine.menu.delete"),
+ disabled: isDeleting,
+ onClick: onDelete,
+ });
+
return (
- {canEdit && menuActions.length > 0 ? (
+ {canEdit ? (
(null);
const [mineOwnership, setMineOwnership] = useState("all");
const [minePage, setMinePage] = useState(1);
const [mineSearch, setMineSearch] = useState("");
@@ -87,32 +82,33 @@ export default function AgentRepositoryPage() {
const [copyOpen, setCopyOpen] = useState(false);
const [copyListing, setCopyListing] = useState(null);
+ useEffect(() => {
+ const tabParam = searchParams.get("tab");
+ if (tabParam === AgentRepositoryTab.MINE) {
+ setTab(AgentRepositoryTab.MINE);
+ return;
+ }
+ if (tabParam === AgentRepositoryTab.REPOSITORY) {
+ setTab(AgentRepositoryTab.REPOSITORY);
+ return;
+ }
+ if (tabParam === AgentRepositoryTab.REVIEW && isAdmin) {
+ setTab(AgentRepositoryTab.REVIEW);
+ }
+ }, [searchParams, isAdmin]);
+
const isRepositoryTab = tab === AgentRepositoryTab.REPOSITORY;
const isReviewTab = tab === AgentRepositoryTab.REVIEW;
const isMineTab = tab === AgentRepositoryTab.MINE;
- const categories = AGENT_REPOSITORY_CATEGORIES;
-
- const categoryNameById = useMemo(
- () =>
- new Map(
- categories.map((item) => [
- item.id,
- getAgentRepositoryCategoryLabel(item, t),
- ])
- ),
- [categories, t]
- );
-
const listingParams = useMemo(
() => ({
status: "shared" as const,
page: repositoryPage,
page_size: REPOSITORY_PAGE_SIZE,
- ...(selectedCategoryId == null ? {} : { category_id: selectedCategoryId }),
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
}),
- [repositoryPage, selectedCategoryId, searchQuery]
+ [repositoryPage, searchQuery]
);
const { data, isLoading, isError, refetch, isFetching } =
@@ -314,11 +310,6 @@ export default function AgentRepositoryPage() {
setRepositoryPage(1);
};
- const handleRepositoryCategoryChange = (categoryId: number | null) => {
- setSelectedCategoryId(categoryId);
- setRepositoryPage(1);
- };
-
return (
@@ -400,10 +391,6 @@ export default function AgentRepositoryPage() {
void;
- categories: AgentRepositoryCategoryItem[];
- categoryNameById: Map;
- selectedCategoryId: number | null;
- onCategoryChange: (categoryId: number | null) => void;
isLoading: boolean;
isError: boolean;
isFetching: boolean;
@@ -589,35 +568,6 @@ function RepositoryView({
/>
-
- onCategoryChange(null)}
- className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${
- selectedCategoryId == null
- ? "bg-primary text-white"
- : "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
- }`}
- >
- {t("agentRepository.page.categoryAll")}
-
- {categories.map((category) => (
- onCategoryChange(category.id)}
- className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${
- selectedCategoryId === category.id
- ? "bg-primary text-white"
- : "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
- }`}
- >
- {categoryNameById.get(category.id) ??
- getAgentRepositoryCategoryLabel(category, t)}
-
- ))}
-
-
{t("agentRepository.page.repositoryHint")}
@@ -647,11 +597,6 @@ function RepositoryView({
();
+ const locale = params.locale || "en";
+ const showBackFromRepository = searchParams.get("from") === "agent-space";
const queryClient = useQueryClient();
const checkUnsavedChanges = useSaveGuard();
const confirm = useConfirmModal();
@@ -578,6 +584,14 @@ export default function AgentSelectorHeader({
return divider ? [agentItem, divider] : [agentItem];
});
+ const handleBackToRepository = async () => {
+ const canLeave = await checkUnsavedChanges.saveWithModal();
+ if (!canLeave) {
+ return;
+ }
+ router.push(`/${locale}/agent-space?tab=mine`);
+ };
+
return (
<>
@@ -594,7 +608,18 @@ export default function AgentSelectorHeader({
lg={12}
className="flex min-w-0"
>
-
+ {showBackFromRepository ? (
+ }
+ onClick={handleBackToRepository}
+ >
+ {t("agentRepository.mine.backToRepository")}
+
+ ) : null}
+
+
diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx
index 8f040d4b3..f8bbda179 100644
--- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx
+++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx
@@ -215,7 +215,6 @@ export default function SkillBuildModal({
}
// Reset task ID
taskIdRef.current = "";
- form.resetFields();
setActiveTab("interactive");
setSelectedSkillName("");
setUploadFile(null);
@@ -242,7 +241,7 @@ export default function SkillBuildModal({
setEditingTabKey(null);
setEditingTabName("");
}
- }, [isOpen, form]);
+ }, [isOpen]);
// Track component mount status for async callback safety
useEffect(() => {
@@ -375,9 +374,14 @@ export default function SkillBuildModal({
}, 200);
};
+ const closeModal = () => {
+ form.resetFields();
+ onCancel();
+ };
+
// Cleanup when modal is closed
const handleModalClose = () => {
- onCancel();
+ closeModal();
};
const handleManualSubmit = async () => {
@@ -399,7 +403,7 @@ export default function SkillBuildModal({
{ ...values, content, files: extraFiles.length > 0 ? extraFiles : undefined } as SkillData,
allSkills,
onSuccess,
- onCancel,
+ closeModal,
t
);
} catch (error) {
@@ -427,7 +431,7 @@ export default function SkillBuildModal({
uploadFile,
allSkills,
onSuccess,
- onCancel,
+ closeModal,
t
);
} finally {
diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
index d62e9ca3d..7e6d8bb92 100644
--- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
+++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
@@ -105,25 +105,13 @@ export default function AgentGenerateDetail({}) {
// Streaming field values (accumulated from SSE, bypasses Form disabled state)
- // Track form values for modal props to avoid synchronous getFieldValue during render
- const [watchedPromptTemplateId, setWatchedPromptTemplateId] = useState(
- form.getFieldValue("promptTemplateId")
- );
- const [watchedBusinessDescription, setWatchedBusinessDescription] = useState(
- form.getFieldValue("businessDescription") || ""
- );
- const [watchedBusinessLogicModelId, setWatchedBusinessLogicModelId] = useState(
- form.getFieldValue("businessLogicModelId")
- );
- const [watchedDutyPrompt, setWatchedDutyPrompt] = useState(
- form.getFieldValue("dutyPrompt") || ""
- );
- const [watchedConstraintPrompt, setWatchedConstraintPrompt] = useState(
- form.getFieldValue("constraintPrompt") || ""
- );
- const [watchedFewShotsPrompt, setWatchedFewShotsPrompt] = useState(
- form.getFieldValue("fewShotsPrompt") || ""
- );
+ // Track form values for modal props (synced after Form mounts / setFieldsValue)
+ const [watchedPromptTemplateId, setWatchedPromptTemplateId] = useState();
+ const [watchedBusinessDescription, setWatchedBusinessDescription] = useState("");
+ const [watchedBusinessLogicModelId, setWatchedBusinessLogicModelId] = useState();
+ const [watchedDutyPrompt, setWatchedDutyPrompt] = useState("");
+ const [watchedConstraintPrompt, setWatchedConstraintPrompt] = useState("");
+ const [watchedFewShotsPrompt, setWatchedFewShotsPrompt] = useState("");
// Modal states
const [expandModalOpen, setExpandModalOpen] = useState(false);
@@ -137,23 +125,13 @@ export default function AgentGenerateDetail({}) {
clearExpiredGenerationCaches();
}, []);
- // Sync watched form values with state to avoid synchronous getFieldValue during render
- useEffect(() => {
- setWatchedPromptTemplateId(form.getFieldValue("promptTemplateId"));
- setWatchedBusinessDescription(form.getFieldValue("businessDescription") || "");
- setWatchedBusinessLogicModelId(form.getFieldValue("businessLogicModelId"));
- setWatchedDutyPrompt(form.getFieldValue("dutyPrompt") || "");
- setWatchedConstraintPrompt(form.getFieldValue("constraintPrompt") || "");
- setWatchedFewShotsPrompt(form.getFieldValue("fewShotsPrompt") || "");
- }, [form]);
-
-
// (e.g. business_description from a previously edited agent)
useEffect(() => {
- if (isCreatingMode) {
+ if (!isCreatingMode) return;
+ queueMicrotask(() => {
form.resetFields();
- }
- }, [isCreatingMode]);
+ });
+ }, [isCreatingMode, form]);
// Use agent generation hook
const { handleGenerateAgent } = useAgentGeneration({
@@ -223,10 +201,22 @@ export default function AgentGenerateDetail({}) {
}
}
+ const defaultAuthor =
+ editedAgent.author?.trim() ||
+ user?.email ||
+ (isSpeedMode ? "Default User" : "");
+
+ if (!editedAgent.author?.trim()) {
+ const resolvedAuthor = user?.email || (isSpeedMode ? "Default User" : "");
+ if (resolvedAuthor) {
+ updateAgentConfig({ author: resolvedAuthor });
+ }
+ }
+
const initialAgentInfo: Record = {
agentName: editedAgent.name || "",
agentDisplayName: editedAgent.display_name || "",
- agentAuthor: editedAgent.author || user?.email || (isSpeedMode ? "Default User" : ""),
+ agentAuthor: defaultAuthor,
mainAgentModels: mainAgentModels,
mainAgentModelIds: mainAgentModelIds,
mainAgentMaxStep: editedAgent.max_step || 15,
@@ -245,17 +235,27 @@ export default function AgentGenerateDetail({}) {
promptTemplateId: editedAgent.prompt_template_id,
promptTemplateName: editedAgent.prompt_template_name || "system_default",
};
- form.setFieldsValue(initialAgentInfo);
+ queueMicrotask(() => {
+ form.setFieldsValue(initialAgentInfo);
+ setWatchedPromptTemplateId(initialAgentInfo.promptTemplateId);
+ setWatchedBusinessDescription(initialAgentInfo.businessDescription || "");
+ setWatchedBusinessLogicModelId(initialAgentInfo.businessLogicModelId);
+ setWatchedDutyPrompt(initialAgentInfo.dutyPrompt || "");
+ setWatchedConstraintPrompt(initialAgentInfo.constraintPrompt || "");
+ setWatchedFewShotsPrompt(initialAgentInfo.fewShotsPrompt || "");
+ });
- }, [form, currentAgentId, editedAgent, isCreatingMode, defaultLlmModel, accessibleGroupIds, forceRefreshKey, availableLlmModels]);
+ }, [form, currentAgentId, editedAgent, isCreatingMode, defaultLlmModel, accessibleGroupIds, forceRefreshKey, availableLlmModels, user?.email, isSpeedMode, updateAgentConfig]);
// Re-validate requested output tokens when the selected model's max changes,
// so switching to a model with a lower cap surfaces the violation immediately
// instead of waiting until save.
useEffect(() => {
- if (form.getFieldValue("requestedOutputTokens") != null) {
- form.validateFields(["requestedOutputTokens"]).catch(() => {});
- }
+ queueMicrotask(() => {
+ if (form.getFieldValue("requestedOutputTokens") != null) {
+ form.validateFields(["requestedOutputTokens"]).catch(() => {});
+ }
+ });
}, [form, selectedMainAgentModel?.maxOutputTokens]);
// Handle business description change
diff --git a/frontend/app/[locale]/agents/components/agentInfo/PromptTemplateManagerModal.tsx b/frontend/app/[locale]/agents/components/agentInfo/PromptTemplateManagerModal.tsx
index a3c6ebded..30fff6873 100644
--- a/frontend/app/[locale]/agents/components/agentInfo/PromptTemplateManagerModal.tsx
+++ b/frontend/app/[locale]/agents/components/agentInfo/PromptTemplateManagerModal.tsx
@@ -102,16 +102,18 @@ export default function PromptTemplateManagerModal({
try {
const systemDefault = await promptTemplateService.detail(0);
const seedTemplate = systemDefault || templates.find((item) => item.template_id === 0) || null;
- editorForm.setFieldsValue({
- template_name: "",
- description: "",
- template_content_zh: seedTemplate?.template_content_zh || createEmptyPromptTemplateContent(),
- template_content_en: seedTemplate?.template_content_en || createEmptyPromptTemplateContent(),
- });
setEditingTemplate(null);
setEditorSeedTemplate(seedTemplate);
setEditorReadOnly(false);
setEditorOpen(true);
+ queueMicrotask(() => {
+ editorForm.setFieldsValue({
+ template_name: "",
+ description: "",
+ template_content_zh: seedTemplate?.template_content_zh || createEmptyPromptTemplateContent(),
+ template_content_en: seedTemplate?.template_content_en || createEmptyPromptTemplateContent(),
+ });
+ });
} catch (error) {
log.error("Failed to load default prompt template:", error);
message.error(t("businessLogic.config.template.loadError"));
diff --git a/frontend/app/[locale]/agents/page.tsx b/frontend/app/[locale]/agents/page.tsx
index 4e625eb3d..f49fd178d 100644
--- a/frontend/app/[locale]/agents/page.tsx
+++ b/frontend/app/[locale]/agents/page.tsx
@@ -81,9 +81,10 @@ export default function AgentSetupOrchestrator() {
const headerStyle: React.CSSProperties = {
padding: 0,
- height: 120,
- lineHeight: '120px',
- background: '#fff',
+ minHeight: 120,
+ height: "auto",
+ lineHeight: "normal",
+ background: "#fff",
flexShrink: 0,
};
diff --git a/frontend/const/agentRepository.ts b/frontend/const/agentRepository.ts
index 162c8af6f..585fdeb82 100644
--- a/frontend/const/agentRepository.ts
+++ b/frontend/const/agentRepository.ts
@@ -1,23 +1,8 @@
/**
- * Agent repository listing presets (categories, icons, preset tags).
+ * Agent repository listing presets (icons, preset tags).
* Display labels are resolved via i18n in agentRepositoryLabels.ts.
*/
-export interface AgentRepositoryCategoryPreset {
- id: number;
- key: string;
-}
-
-export const AGENT_REPOSITORY_CATEGORIES: AgentRepositoryCategoryPreset[] = [
- { id: 1, key: "writing_assistant" },
- { id: 2, key: "programming" },
- { id: 3, key: "data_analysis" },
- { id: 4, key: "customer_service" },
- { id: 5, key: "productivity" },
- { id: 6, key: "creative_design" },
- { id: 0, key: "other" },
-];
-
export const AGENT_REPOSITORY_ICONS = [
"🤖",
"✍️",
@@ -53,9 +38,3 @@ export const AGENT_REPOSITORY_PRESET_TAGS = [
"spreadsheet",
"office",
] as const;
-
-/** Map category id to stable key for label resolution. */
-export const AGENT_REPOSITORY_CATEGORY_ID_TO_KEY: Record =
- Object.fromEntries(
- AGENT_REPOSITORY_CATEGORIES.map((category) => [category.id, category.key])
- );
diff --git a/frontend/lib/agentRepositoryDetail.test.ts b/frontend/lib/agentRepositoryDetail.test.ts
deleted file mode 100644
index 197c9f3c8..000000000
--- a/frontend/lib/agentRepositoryDetail.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import assert from "node:assert/strict";
-import { describe, it } from "node:test";
-import {
- mapAgentVersionDetail,
- mapRepositoryListingDetail,
-} from "./agentRepositoryDetail";
-import type { AgentVersionDetail } from "@/services/agentVersionService";
-import type { AgentRepositoryListingDetail } from "@/types/agentRepository";
-
-describe("mapRepositoryListingDetail", () => {
- it("maps repository listing fields including status", () => {
- const detail: AgentRepositoryListingDetail = {
- agent_repository_id: 1,
- name: "agent_one",
- display_name: "Agent One",
- description: "desc",
- status: "shared",
- version_label: "v1",
- downloads: 5,
- model_name: "gpt",
- duty_prompt: "help users",
- tools: ["search", "calc"],
- };
-
- const result = mapRepositoryListingDetail(detail);
-
- assert.equal(result.status, "shared");
- assert.equal(result.display_name, "Agent One");
- assert.deepEqual(result.tools, ["search", "calc"]);
- });
-});
-
-describe("mapAgentVersionDetail", () => {
- it("maps version detail without marketplace status", () => {
- const detail = {
- agent_id: 10,
- name: "draft_agent",
- display_name: "Draft Agent",
- description: "draft desc",
- model_name: "gpt",
- duty_prompt: "assist",
- tools: [
- { origin_name: "web_search" },
- { name: "calculator" },
- { tool_id: 3 },
- ],
- version: {
- version_name: "Draft",
- create_time: "2026-01-01T00:00:00Z",
- },
- } as AgentVersionDetail;
-
- const result = mapAgentVersionDetail(detail);
-
- assert.equal(result.status, undefined);
- assert.equal(result.version_label, "Draft");
- assert.equal(result.created_at, "2026-01-01T00:00:00Z");
- assert.deepEqual(result.tools, ["web_search", "calculator"]);
- });
-});
diff --git a/frontend/lib/agentRepositoryDetail.ts b/frontend/lib/agentRepositoryDetail.ts
index f594fb5e1..a69c5834e 100644
--- a/frontend/lib/agentRepositoryDetail.ts
+++ b/frontend/lib/agentRepositoryDetail.ts
@@ -8,6 +8,7 @@ export interface AgentDetailModalData {
name: string;
display_name?: string | null;
description?: string | null;
+ author?: string | null;
icon?: string | null;
status?: AgentRepositoryListingStatus;
version_label?: string | null;
@@ -41,6 +42,7 @@ export function mapRepositoryListingDetail(
name: detail.name,
display_name: detail.display_name,
description: detail.description,
+ author: detail.author,
icon: detail.icon,
status: detail.status,
version_label: detail.version_label,
@@ -63,6 +65,7 @@ export function mapAgentVersionDetail(
name: detail.name,
display_name: detail.display_name,
description: detail.description,
+ author: detail.author,
model_name: detail.model_name,
duty_prompt: detail.duty_prompt,
version_label: versionMeta?.version_name ?? null,
diff --git a/frontend/lib/agentRepositoryIcon.ts b/frontend/lib/agentRepositoryIcon.ts
new file mode 100644
index 000000000..48844dad2
--- /dev/null
+++ b/frontend/lib/agentRepositoryIcon.ts
@@ -0,0 +1,27 @@
+const EMOJI_PICTOGRAPHIC = /\p{Extended_Pictographic}/gu;
+const EMOJI_MODIFIERS = /[\uFE0E\uFE0F\u{1F3FB}-\u{1F3FF}]/gu;
+
+/**
+ * Returns true when value is a single simple emoji (no ZWJ compounds, flags, or text).
+ */
+export function isSingleSimpleEmoji(value: string): boolean {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return false;
+ }
+
+ if (trimmed.includes("\u200D")) {
+ return false;
+ }
+
+ const pictographics = trimmed.match(EMOJI_PICTOGRAPHIC) ?? [];
+ if (pictographics.length !== 1) {
+ return false;
+ }
+
+ const remainder = trimmed
+ .replace(EMOJI_PICTOGRAPHIC, "")
+ .replace(EMOJI_MODIFIERS, "");
+
+ return remainder.length === 0;
+}
diff --git a/frontend/lib/agentRepositoryLabels.test.ts b/frontend/lib/agentRepositoryLabels.test.ts
deleted file mode 100644
index 262a6e635..000000000
--- a/frontend/lib/agentRepositoryLabels.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import assert from "node:assert/strict";
-import { describe, it } from "node:test";
-import type { TFunction } from "i18next";
-import {
- getAgentRepositoryCategoryLabel,
- getAgentRepositoryTagLabel,
- getAgentRepositoryTagSearchText,
-} from "./agentRepositoryLabels";
-
-const t = ((key: string) => {
- const translations: Record = {
- "agentRepository.category.writingAssistant": "Writing Assistant",
- "agentRepository.category.other": "Other",
- "agentRepository.tag.marketing": "Marketing",
- "agentRepository.tag.codeReview": "Code Review",
- "agentRepository.review.unknownCategory": "Uncategorized",
- };
- return translations[key] ?? key;
-}) as TFunction;
-
-describe("agentRepositoryLabels", () => {
- it("localizes category by stable key", () => {
- const label = getAgentRepositoryCategoryLabel(
- { id: 1, key: "writing_assistant", name: "写作助手" },
- t
- );
- assert.equal(label, "Writing Assistant");
- });
-
- it("localizes preset tag keys", () => {
- assert.equal(getAgentRepositoryTagLabel("marketing", t), "Marketing");
- });
-
- it("localizes legacy Chinese tag values", () => {
- assert.equal(getAgentRepositoryTagLabel("代码审查", t), "Code Review");
- });
-
- it("returns custom tags unchanged", () => {
- assert.equal(getAgentRepositoryTagLabel("my-custom-tag", t), "my-custom-tag");
- });
-
- it("includes localized text in tag search text", () => {
- const searchText = getAgentRepositoryTagSearchText("marketing", t);
- assert.match(searchText, /marketing/);
- assert.match(searchText, /Marketing/);
- });
-});
diff --git a/frontend/lib/agentRepositoryLabels.ts b/frontend/lib/agentRepositoryLabels.ts
index f9430db9a..b96ab15e9 100644
--- a/frontend/lib/agentRepositoryLabels.ts
+++ b/frontend/lib/agentRepositoryLabels.ts
@@ -1,36 +1,10 @@
/**
- * Label resolvers for agent repository categories and preset tags.
+ * Label resolvers for agent repository preset tags.
* Presets live in const/agentRepository.ts; localized labels come from i18n.
*/
import type { TFunction } from "i18next";
-import {
- AGENT_REPOSITORY_CATEGORY_ID_TO_KEY,
- AGENT_REPOSITORY_PRESET_TAGS,
-} from "@/const/agentRepository";
-import type { AgentRepositoryCategoryItem } from "@/types/agentRepository";
-
-/** Map stable category key to i18n key suffix under agentRepository.category.* */
-const CATEGORY_KEY_TO_I18N: Record = {
- writing_assistant: "writingAssistant",
- programming: "programming",
- data_analysis: "dataAnalysis",
- customer_service: "customerService",
- productivity: "productivity",
- creative_design: "creativeDesign",
- other: "other",
-};
-
-/** Legacy Chinese category names from older API responses. */
-const LEGACY_CATEGORY_NAME_TO_KEY: Record = {
- 写作助手: "writing_assistant",
- 编程开发: "programming",
- 数据分析: "data_analysis",
- 客户服务: "customer_service",
- 效率工具: "productivity",
- 创意设计: "creative_design",
- 其它: "other",
-};
+import { AGENT_REPOSITORY_PRESET_TAGS } from "@/const/agentRepository";
/** Map preset tag key to i18n key suffix under agentRepository.tag.* */
const TAG_KEY_TO_I18N: Record = Object.fromEntries(
@@ -69,17 +43,6 @@ const LEGACY_TAG_VALUE_TO_KEY: Record = {
办公: "office",
};
-function resolveCategoryKey(category: AgentRepositoryCategoryItem): string | null {
- if (category.key?.trim()) {
- return category.key.trim();
- }
- if (category.id in AGENT_REPOSITORY_CATEGORY_ID_TO_KEY) {
- return AGENT_REPOSITORY_CATEGORY_ID_TO_KEY[category.id];
- }
- const legacyKey = LEGACY_CATEGORY_NAME_TO_KEY[category.name?.trim() ?? ""];
- return legacyKey ?? null;
-}
-
function resolveTagKey(tag: string): string | null {
const trimmed = tag.trim();
if (!trimmed) {
@@ -92,61 +55,40 @@ function resolveTagKey(tag: string): string | null {
}
/**
- * Get localized label for a repository category option.
+ * Get localized label for a repository tag (preset key or legacy Chinese value).
+ * Custom tags are returned unchanged.
*/
-export function getAgentRepositoryCategoryLabel(
- category: AgentRepositoryCategoryItem,
- t: TFunction
-): string {
- const stableKey = resolveCategoryKey(category);
+export function getAgentRepositoryTagLabel(tag: string, t: TFunction): string {
+ const stableKey = resolveTagKey(tag);
if (stableKey) {
- const i18nSuffix = CATEGORY_KEY_TO_I18N[stableKey];
+ const i18nSuffix = TAG_KEY_TO_I18N[stableKey];
if (i18nSuffix) {
- const i18nKey = `agentRepository.category.${i18nSuffix}`;
+ const i18nKey = `agentRepository.tag.${i18nSuffix}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
}
}
- return category.name?.trim() || t("agentRepository.review.unknownCategory");
+ return tag.trim();
}
/**
- * Get localized label for a category id using a prebuilt category list.
+ * Resolve a tag for API submission: preset tags become the localized i18n value
+ * for the current locale; custom tags are trimmed as-is.
*/
-export function getAgentRepositoryCategoryLabelById(
- categoryId: number | null | undefined,
- categories: AgentRepositoryCategoryItem[],
+export function resolveAgentRepositoryTagForSubmit(
+ tag: string,
t: TFunction
): string {
- if (categoryId == null) {
- return t("agentRepository.review.unknownCategory");
- }
- const category = categories.find((item) => item.id === categoryId);
- if (!category) {
- return t("agentRepository.review.unknownCategory");
+ const trimmed = tag.trim();
+ if (!trimmed) {
+ return trimmed;
}
- return getAgentRepositoryCategoryLabel(category, t);
-}
-
-/**
- * Get localized label for a repository tag (preset key or legacy Chinese value).
- * Custom tags are returned unchanged.
- */
-export function getAgentRepositoryTagLabel(tag: string, t: TFunction): string {
- const stableKey = resolveTagKey(tag);
- if (stableKey) {
- const i18nSuffix = TAG_KEY_TO_I18N[stableKey];
- if (i18nSuffix) {
- const i18nKey = `agentRepository.tag.${i18nSuffix}`;
- const translated = t(i18nKey);
- if (translated !== i18nKey) {
- return translated;
- }
- }
+ if (resolveTagKey(trimmed)) {
+ return getAgentRepositoryTagLabel(trimmed, t);
}
- return tag.trim();
+ return trimmed;
}
/**
diff --git a/frontend/lib/agentRepositoryMine.test.ts b/frontend/lib/agentRepositoryMine.test.ts
deleted file mode 100644
index 8ff9c5959..000000000
--- a/frontend/lib/agentRepositoryMine.test.ts
+++ /dev/null
@@ -1,555 +0,0 @@
-import assert from "node:assert/strict";
-import { describe, it } from "node:test";
-
-import {
- buildApplyListingFormPrefill,
- getMineCardMenuActions,
- getMineCardRepositoryStatusBadge,
- isCancelableRepositoryStatus,
- isCurrentVersionListed,
- pickApplyListingPrefillSource,
- pickReviewDisplayRepositoryInfo,
-} from "./agentRepositoryMine";
-import type {
- AgentRepositoryListingItem,
- MyAgentRepositoryInfoItem,
- MyEditableAgentItem,
-} from "../types/agentRepository";
-
-function makeAgent(
- overrides: Partial = {}
-): MyEditableAgentItem {
- return {
- agent_id: 1,
- repository_info: [],
- ...overrides,
- };
-}
-
-function makeRepoInfo(
- overrides: Partial
-): MyAgentRepositoryInfoItem {
- return {
- agent_repository_id: 1,
- status: "pending_review",
- version_no: 1,
- version_label: "v1",
- create_time: "2026-06-01T00:00:00.000Z",
- ...overrides,
- };
-}
-
-function makeListingItem(
- overrides: Partial = {}
-): AgentRepositoryListingItem {
- return {
- agent_repository_id: 1,
- name: "Test Agent",
- status: "shared",
- ...overrides,
- };
-}
-
-const ALLOWED_ICONS = ["🤖", "✍️"] as const;
-const ALLOWED_CATEGORY_IDS = [1, 2] as const;
-
-describe("agentRepositoryMine menu helpers", () => {
- it("returns apply only for published agent without matching repository version", () => {
- const agent = makeAgent({
- current_version_no: 2,
- repository_info: [],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["apply"]);
- assert.equal(isCurrentVersionListed(agent), false);
- });
-
- it("does not return apply for read-only agents", () => {
- const agent = makeAgent({
- current_version_no: 2,
- permission: "READ_ONLY",
- repository_info: [],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), []);
- });
-
- it("returns review only when repository has pending_review without shared", () => {
- const agent = makeAgent({
- current_version_no: 1,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 10,
- status: "pending_review",
- version_no: 1,
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["review"]);
- });
-
- it("returns reviewUpdate when both pending_review and shared exist", () => {
- const agent = makeAgent({
- current_version_no: 3,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 11,
- status: "shared",
- version_no: 2,
- create_time: "2026-05-01T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 12,
- status: "pending_review",
- version_no: 3,
- create_time: "2026-06-20T00:00:00.000Z",
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]);
- });
-
- it("returns apply and reviewUpdate when current version is not listed yet", () => {
- const agent = makeAgent({
- current_version_no: 3,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 11,
- status: "shared",
- version_no: 2,
- }),
- makeRepoInfo({
- agent_repository_id: 12,
- status: "pending_review",
- version_no: 4,
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["apply", "reviewUpdate"]);
- });
-
- it("pickReviewDisplayRepositoryInfo prefers latest pending_review", () => {
- const items = [
- makeRepoInfo({
- agent_repository_id: 20,
- status: "shared",
- version_no: 1,
- create_time: "2026-06-10T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 21,
- status: "pending_review",
- version_no: 2,
- create_time: "2026-06-18T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 22,
- status: "pending_review",
- version_no: 3,
- create_time: "2026-06-20T00:00:00.000Z",
- }),
- ];
-
- const picked = pickReviewDisplayRepositoryInfo(items);
- assert.equal(picked?.agent_repository_id, 22);
- });
-
- it("pickReviewDisplayRepositoryInfo falls back to latest shared", () => {
- const items = [
- makeRepoInfo({
- agent_repository_id: 30,
- status: "shared",
- version_no: 1,
- create_time: "2026-05-01T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 31,
- status: "shared",
- version_no: 2,
- create_time: "2026-06-01T00:00:00.000Z",
- }),
- ];
-
- const picked = pickReviewDisplayRepositoryInfo(items);
- assert.equal(picked?.agent_repository_id, 31);
- });
-
- it("returns review when only rejected exists", () => {
- const agent = makeAgent({
- current_version_no: 1,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 40,
- status: "rejected",
- version_no: 1,
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["review"]);
- });
-
- it("pickReviewDisplayRepositoryInfo falls back to latest rejected", () => {
- const items = [
- makeRepoInfo({
- agent_repository_id: 50,
- status: "rejected",
- version_no: 1,
- create_time: "2026-05-01T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 51,
- status: "rejected",
- version_no: 2,
- create_time: "2026-06-01T00:00:00.000Z",
- }),
- ];
-
- const picked = pickReviewDisplayRepositoryInfo(items);
- assert.equal(picked?.agent_repository_id, 51);
- });
-
- it("returns reviewUpdate and prefers pending when pending shared and rejected coexist", () => {
- const agent = makeAgent({
- current_version_no: 3,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 60,
- status: "shared",
- version_no: 2,
- create_time: "2026-05-01T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 61,
- status: "rejected",
- version_no: 1,
- create_time: "2026-04-01T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 62,
- status: "pending_review",
- version_no: 3,
- create_time: "2026-06-20T00:00:00.000Z",
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]);
- const picked = pickReviewDisplayRepositoryInfo(agent.repository_info);
- assert.equal(picked?.agent_repository_id, 62);
- });
-
- it("returns reviewUpdate and prefers rejected over shared when no pending", () => {
- const agent = makeAgent({
- current_version_no: 2,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 70,
- status: "rejected",
- version_no: 2,
- version_label: "V2",
- create_time: "2026-06-23T11:27:47.698555Z",
- }),
- makeRepoInfo({
- agent_repository_id: 71,
- status: "shared",
- version_no: 1,
- version_label: "V1",
- create_time: "2026-06-23T11:18:47.034823Z",
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]);
- const picked = pickReviewDisplayRepositoryInfo(agent.repository_info);
- assert.equal(picked?.agent_repository_id, 70);
- });
-
- it("matches user scenario with rejected V2 and shared V1", () => {
- const agent = makeAgent({
- agent_id: 35,
- current_version_no: 2,
- repository_info: [
- makeRepoInfo({
- agent_repository_id: 7,
- status: "rejected",
- version_no: 2,
- version_label: "V2",
- create_time: "2026-06-23T11:27:47.698555Z",
- }),
- makeRepoInfo({
- agent_repository_id: 6,
- status: "shared",
- version_no: 1,
- version_label: "V1",
- create_time: "2026-06-23T11:18:47.034823Z",
- }),
- ],
- });
-
- assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]);
- const picked = pickReviewDisplayRepositoryInfo(agent.repository_info);
- assert.equal(picked?.agent_repository_id, 7);
- assert.equal(picked?.status, "rejected");
- });
-
- it("returns no actions for draft agent with empty repository info", () => {
- const agent = makeAgent({ current_version_no: 0, repository_info: [] });
- assert.deepEqual(getMineCardMenuActions(agent), []);
- });
-
- it("isCancelableRepositoryStatus allows pending_review and rejected only", () => {
- assert.equal(isCancelableRepositoryStatus("pending_review"), true);
- assert.equal(isCancelableRepositoryStatus("rejected"), true);
- assert.equal(isCancelableRepositoryStatus("shared"), false);
- });
-});
-
-describe("getMineCardRepositoryStatusBadge", () => {
- it("returns null for empty repository info", () => {
- assert.equal(getMineCardRepositoryStatusBadge([]), null);
- });
-
- it("returns reviewing badge for pending_review only", () => {
- const badge = getMineCardRepositoryStatusBadge([
- makeRepoInfo({
- status: "pending_review",
- version_label: "v2.0",
- }),
- ]);
-
- assert.deepEqual(badge, {
- labelKey: "agentRepository.mine.status.reviewing",
- versionLabel: "v2.0",
- variant: "pending",
- });
- });
-
- it("returns updateReviewing badge when pending_review and shared coexist", () => {
- const badge = getMineCardRepositoryStatusBadge([
- makeRepoInfo({
- agent_repository_id: 1,
- status: "shared",
- version_label: "v2.1.0",
- version_no: 2,
- create_time: "2026-05-01T00:00:00.000Z",
- }),
- makeRepoInfo({
- agent_repository_id: 2,
- status: "pending_review",
- version_label: "v2.3.0",
- version_no: 3,
- create_time: "2026-06-20T00:00:00.000Z",
- }),
- ]);
-
- assert.deepEqual(badge, {
- labelKey: "agentRepository.mine.status.updateReviewing",
- versionLabel: "v2.3.0",
- variant: "pending",
- });
- });
-
- it("returns listed badge for shared only", () => {
- const badge = getMineCardRepositoryStatusBadge([
- makeRepoInfo({
- status: "shared",
- version_label: "v1.0",
- }),
- ]);
-
- assert.deepEqual(badge, {
- labelKey: "agentRepository.mine.status.listed",
- versionLabel: "v1.0",
- variant: "shared",
- });
- });
-
- it("returns rejected badge for rejected only", () => {
- const badge = getMineCardRepositoryStatusBadge([
- makeRepoInfo({
- status: "rejected",
- version_label: "V2",
- }),
- ]);
-
- assert.deepEqual(badge, {
- labelKey: "agentRepository.mine.status.rejected",
- versionLabel: "V2",
- variant: "rejected",
- });
- });
-
- it("prefers rejected over shared when both exist without pending", () => {
- const badge = getMineCardRepositoryStatusBadge([
- makeRepoInfo({
- agent_repository_id: 70,
- status: "rejected",
- version_no: 2,
- version_label: "V2",
- create_time: "2026-06-23T11:27:47.698555Z",
- }),
- makeRepoInfo({
- agent_repository_id: 71,
- status: "shared",
- version_no: 1,
- version_label: "V1",
- create_time: "2026-06-23T11:18:47.034823Z",
- }),
- ]);
-
- assert.deepEqual(badge, {
- labelKey: "agentRepository.mine.status.rejected",
- versionLabel: "V2",
- variant: "rejected",
- });
- });
-
- it("falls back to version_no when version_label is missing", () => {
- const badge = getMineCardRepositoryStatusBadge([
- makeRepoInfo({
- status: "pending_review",
- version_label: null,
- version_no: 3,
- }),
- ]);
-
- assert.deepEqual(badge, {
- labelKey: "agentRepository.mine.status.reviewing",
- versionLabel: "v3",
- variant: "pending",
- });
- });
-});
-
-describe("pickApplyListingPrefillSource", () => {
- it("prefers matching version_label over shared", () => {
- const items = [
- makeListingItem({
- agent_repository_id: 10,
- status: "shared",
- version_label: "v2.1.0",
- icon: "🤖",
- }),
- makeListingItem({
- agent_repository_id: 11,
- status: "rejected",
- version_label: "v2.3.0",
- icon: "✍️",
- }),
- ];
-
- const picked = pickApplyListingPrefillSource(items, "v2.3.0");
- assert.equal(picked?.agent_repository_id, 11);
- });
-
- it("falls back to first shared when version_label does not match", () => {
- const items = [
- makeListingItem({
- agent_repository_id: 20,
- status: "shared",
- version_label: "v2.1.0",
- }),
- makeListingItem({
- agent_repository_id: 21,
- status: "pending_review",
- version_label: "v2.3.0",
- }),
- ];
-
- const picked = pickApplyListingPrefillSource(items, "v3.0.0");
- assert.equal(picked?.agent_repository_id, 20);
- assert.equal(picked?.status, "shared");
- });
-
- it("returns null when only pending_review or rejected exist", () => {
- const items = [
- makeListingItem({
- status: "pending_review",
- version_label: "v1",
- }),
- makeListingItem({
- agent_repository_id: 2,
- status: "rejected",
- version_label: "v2",
- }),
- ];
-
- assert.equal(pickApplyListingPrefillSource(items, "v3"), null);
- assert.equal(pickApplyListingPrefillSource(items, null), null);
- });
-
- it("returns null for empty items", () => {
- assert.equal(pickApplyListingPrefillSource([], "v1"), null);
- });
-});
-
-describe("buildApplyListingFormPrefill", () => {
- it("maps valid icon, category_id, and tags", () => {
- const prefill = buildApplyListingFormPrefill(
- makeListingItem({
- icon: "✍️",
- category_id: 2,
- tags: ["marketing", "copywriting"],
- }),
- {
- allowedIcons: ALLOWED_ICONS,
- allowedCategoryIds: ALLOWED_CATEGORY_IDS,
- }
- );
-
- assert.deepEqual(prefill, {
- icon: "✍️",
- categoryId: 2,
- tags: ["marketing", "copywriting"],
- });
- });
-
- it("returns null when source item is null", () => {
- assert.equal(
- buildApplyListingFormPrefill(null, {
- allowedIcons: ALLOWED_ICONS,
- allowedCategoryIds: ALLOWED_CATEGORY_IDS,
- }),
- null
- );
- });
-
- it("ignores invalid icon and category_id but keeps tags", () => {
- const prefill = buildApplyListingFormPrefill(
- makeListingItem({
- icon: "invalid",
- category_id: 99,
- tags: ["marketing"],
- }),
- {
- allowedIcons: ALLOWED_ICONS,
- allowedCategoryIds: ALLOWED_CATEGORY_IDS,
- }
- );
-
- assert.deepEqual(prefill, {
- icon: null,
- categoryId: null,
- tags: ["marketing"],
- });
- });
-
- it("normalizes and truncates tags", () => {
- const prefill = buildApplyListingFormPrefill(
- makeListingItem({
- tags: [" marketing ", "marketing", "copywriting", "a", "b", "c"],
- }),
- {
- allowedIcons: ALLOWED_ICONS,
- allowedCategoryIds: ALLOWED_CATEGORY_IDS,
- maxTags: 3,
- }
- );
-
- assert.deepEqual(prefill?.tags, ["marketing", "copywriting", "a"]);
- });
-});
diff --git a/frontend/lib/agentRepositoryMine.ts b/frontend/lib/agentRepositoryMine.ts
index 67ec1712c..54fa4b520 100644
--- a/frontend/lib/agentRepositoryMine.ts
+++ b/frontend/lib/agentRepositoryMine.ts
@@ -3,6 +3,7 @@ import type {
MyAgentRepositoryInfoItem,
MyEditableAgentItem,
} from "@/types/agentRepository";
+import { isSingleSimpleEmoji } from "@/lib/agentRepositoryIcon";
export type MineCardMenuAction = "apply" | "review" | "reviewUpdate";
@@ -204,7 +205,6 @@ export function pickApplyListingPrefillSource(
export interface ApplyListingFormPrefill {
icon: string | null;
- categoryId: number | null;
tags: string[];
}
@@ -228,30 +228,20 @@ function normalizeApplyListingTags(tags: string[], maxTags: number): string[] {
export function buildApplyListingFormPrefill(
item: AgentRepositoryListingItem | null,
options: {
- allowedIcons: readonly string[];
- allowedCategoryIds: readonly number[];
maxTags?: number;
- }
+ } = {}
): ApplyListingFormPrefill | null {
if (!item) {
return null;
}
const maxTags = options.maxTags ?? 5;
- const allowedIconSet = new Set(options.allowedIcons);
- const allowedCategorySet = new Set(options.allowedCategoryIds);
+ const trimmedIcon = item.icon?.trim();
const icon =
- item.icon?.trim() && allowedIconSet.has(item.icon.trim())
- ? item.icon.trim()
- : null;
-
- const categoryId =
- item.category_id != null && allowedCategorySet.has(item.category_id)
- ? item.category_id
- : null;
+ trimmedIcon && isSingleSimpleEmoji(trimmedIcon) ? trimmedIcon : null;
const tags = normalizeApplyListingTags(item.tags ?? [], maxTags);
- return { icon, categoryId, tags };
+ return { icon, tags };
}
diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index b08783eea..5ecebc99b 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -1757,7 +1757,7 @@
"agentRepository.page.tab.repository": "Repository",
"agentRepository.page.tab.mine": "My Agents",
"agentRepository.page.tab.review": "Review Center",
- "agentRepository.page.searchPlaceholder": "Search by name, description, or author",
+ "agentRepository.page.searchPlaceholder": "Search by name, description, or tags",
"agentRepository.page.categoryAll": "All",
"agentRepository.page.repositoryHint": "Agents in the shared repository must be copied to your workspace before you can edit them.",
"agentRepository.page.resultCount": "{{count}} agents",
@@ -1787,12 +1787,14 @@
"agentRepository.mine.currentVersion": "Current {{version}}",
"agentRepository.mine.onlineVersionShort": "Live {{version}}",
"agentRepository.mine.edit": "Edit",
+ "agentRepository.mine.backToRepository": "Back to Agent Repository",
"agentRepository.mine.view": "View",
"agentRepository.mine.evaluate": "Evaluate",
"agentRepository.mine.menu.more": "More actions",
"agentRepository.mine.menu.apply": "Apply to list",
"agentRepository.mine.menu.review": "View review status",
"agentRepository.mine.menu.reviewUpdate": "View update review status",
+ "agentRepository.mine.menu.delete": "Delete",
"agentRepository.mine.reviewModal.title": "Listing review status",
"agentRepository.mine.reviewModal.reviewUpdateTitle": "Update review status",
"agentRepository.mine.reviewModal.agentName": "Listing review progress for \"{{name}}\"",
@@ -1813,13 +1815,17 @@
"agentRepository.mine.applyModal.title": "Apply to list",
"agentRepository.mine.applyModal.agentName": "Apply to list \"{{name}}\"",
"agentRepository.mine.applyModal.icon": "Agent icon",
+ "agentRepository.mine.applyModal.customIconPlaceholder": "Enter emoji",
+ "agentRepository.mine.applyModal.customIconHint": "Select or customize an icon",
+ "agentRepository.mine.applyModal.iconPresetPicker": "Choose a preset icon",
"agentRepository.mine.applyModal.category": "Category",
"agentRepository.mine.applyModal.categoryPlaceholder": "Select a category",
"agentRepository.mine.applyModal.tags": "Tags",
"agentRepository.mine.applyModal.tagsPlaceholder": "Select or enter tags",
"agentRepository.mine.applyModal.tagsHint": "Choose up to {{count}} tags. Custom tags are allowed.",
"agentRepository.mine.applyModal.submit": "Submit request",
- "agentRepository.mine.applyModal.validation.icon": "Please select an agent icon",
+ "agentRepository.mine.applyModal.validation.icon": "Please select or enter an agent icon",
+ "agentRepository.mine.applyModal.validation.iconInvalid": "Please enter a single valid emoji",
"agentRepository.mine.applyModal.validation.category": "Please select a category",
"agentRepository.mine.applyModal.validation.tags": "Please add at least one tag",
"agentRepository.mine.applyModal.validation.tagsMax": "You can select at most {{count}} tags",
@@ -1869,6 +1875,7 @@
"agentRepository.copy.reason.tool_unavailable": "Tool unavailable",
"agentRepository.detail.intro": "Introduction",
+ "agentRepository.detail.author": "{{author}}",
"agentRepository.detail.tools": "Built-in Tools",
"agentRepository.detail.role": "Agent Role",
"agentRepository.detail.downloads": "{{count}} installs",
diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json
index e710808b1..4937946b3 100644
--- a/frontend/public/locales/zh/common.json
+++ b/frontend/public/locales/zh/common.json
@@ -1725,7 +1725,7 @@
"agentRepository.page.tab.repository": "仓库",
"agentRepository.page.tab.mine": "我的 Agent",
"agentRepository.page.tab.review": "审核中心",
- "agentRepository.page.searchPlaceholder": "搜索智能体名称、描述或作者",
+ "agentRepository.page.searchPlaceholder": "搜索智能体名称、描述或标签",
"agentRepository.page.categoryAll": "全部",
"agentRepository.page.repositoryHint": "同租户内的智能体需先「复制为我的智能体」后才能编辑",
"agentRepository.page.resultCount": "共 {{count}} 个智能体",
@@ -1755,12 +1755,14 @@
"agentRepository.mine.currentVersion": "当前 {{version}}",
"agentRepository.mine.onlineVersionShort": "线上 {{version}}",
"agentRepository.mine.edit": "编辑",
+ "agentRepository.mine.backToRepository": "返回Agent 仓库",
"agentRepository.mine.view": "查看",
"agentRepository.mine.evaluate": "评估",
"agentRepository.mine.menu.more": "更多操作",
"agentRepository.mine.menu.apply": "申请上架",
"agentRepository.mine.menu.review": "查看审核进度",
"agentRepository.mine.menu.reviewUpdate": "查看更新审核进度",
+ "agentRepository.mine.menu.delete": "删除",
"agentRepository.mine.reviewModal.title": "上架审核状态",
"agentRepository.mine.reviewModal.reviewUpdateTitle": "更新审核状态",
"agentRepository.mine.reviewModal.agentName": "「{{name}}」的上架申请进度",
@@ -1781,13 +1783,17 @@
"agentRepository.mine.applyModal.title": "申请上架",
"agentRepository.mine.applyModal.agentName": "为「{{name}}」申请上架",
"agentRepository.mine.applyModal.icon": "智能体图标",
+ "agentRepository.mine.applyModal.customIconPlaceholder": "输入 emoji",
+ "agentRepository.mine.applyModal.customIconHint": "选择或自定义图标",
+ "agentRepository.mine.applyModal.iconPresetPicker": "选择预设图标",
"agentRepository.mine.applyModal.category": "智能体类别",
"agentRepository.mine.applyModal.categoryPlaceholder": "请选择类别",
"agentRepository.mine.applyModal.tags": "智能体标签",
"agentRepository.mine.applyModal.tagsPlaceholder": "选择或输入标签",
"agentRepository.mine.applyModal.tagsHint": "最多选择 {{count}} 个标签,可输入自定义标签",
"agentRepository.mine.applyModal.submit": "提交申请",
- "agentRepository.mine.applyModal.validation.icon": "请选择智能体图标",
+ "agentRepository.mine.applyModal.validation.icon": "请选择或输入智能体图标",
+ "agentRepository.mine.applyModal.validation.iconInvalid": "请输入单个有效的 emoji",
"agentRepository.mine.applyModal.validation.category": "请选择智能体类别",
"agentRepository.mine.applyModal.validation.tags": "请至少选择一个标签",
"agentRepository.mine.applyModal.validation.tagsMax": "最多只能选择 {{count}} 个标签",
@@ -1837,6 +1843,7 @@
"agentRepository.copy.reason.tool_unavailable": "工具不可用",
"agentRepository.detail.intro": "智能体简介",
+ "agentRepository.detail.author": "{{author}}",
"agentRepository.detail.tools": "内置工具",
"agentRepository.detail.role": "智能体角色",
"agentRepository.detail.downloads": "{{count}} 次安装",
diff --git a/frontend/services/api.ts b/frontend/services/api.ts
index ae64c4259..349430daa 100644
--- a/frontend/services/api.ts
+++ b/frontend/services/api.ts
@@ -434,9 +434,6 @@ export const API_ENDPOINTS = {
if (params?.agent_id != null) {
queryParams.append("agent_id", String(params.agent_id));
}
- if (params?.category_id != null) {
- queryParams.append("category_id", String(params.category_id));
- }
if (params?.page != null) {
queryParams.append("page", String(params.page));
}
diff --git a/frontend/types/agentRepository.ts b/frontend/types/agentRepository.ts
index 985f984dd..c7e30377f 100644
--- a/frontend/types/agentRepository.ts
+++ b/frontend/types/agentRepository.ts
@@ -21,7 +21,6 @@ export interface AgentRepositoryListingItem {
tool_count?: number | null;
version_label?: string | null;
downloads?: number;
- category_id?: number | null;
submitted_by?: string | null;
}
@@ -40,19 +39,11 @@ export interface AgentRepositoryListingListResponse {
export interface AgentRepositoryListingListParams {
status?: AgentRepositoryListingStatus;
agent_id?: number;
- category_id?: number;
page?: number;
page_size?: number;
search?: string;
}
-export interface AgentRepositoryCategoryItem {
- id: number;
- key: string;
- /** Legacy fallback when resolving labels from old API payloads. */
- name?: string;
-}
-
export interface AgentRepositoryListingDetail {
agent_repository_id: number;
agent_id?: number | null;
@@ -140,7 +131,6 @@ export interface MyEditableAgentListResponse {
export interface AgentRepositoryListingCreatePayload {
icon: string;
- category_id: number;
tags: string[];
}
diff --git a/test/backend/app/test_agent_repository_app.py b/test/backend/app/test_agent_repository_app.py
index 2d1083c9a..2611e3aa7 100644
--- a/test/backend/app/test_agent_repository_app.py
+++ b/test/backend/app/test_agent_repository_app.py
@@ -25,7 +25,6 @@ class _AgentRepositoryListingCreateRequest(BaseModel):
icon: Optional[str] = None
downloads: int = Field(0, ge=0)
tags: Optional[List[str]] = None
- category_id: Optional[int] = 0
tool_count: Optional[int] = Field(None, ge=0)
@@ -67,7 +66,6 @@ def test_list_agent_repository_listings_api_success(
"test_tenant_id",
status=None,
agent_id=None,
- category_id=None,
page=1,
page_size=10,
search=None,
@@ -99,7 +97,6 @@ def test_list_agent_repository_listings_api_passes_agent_id(
"test_tenant_id",
status=None,
agent_id=123,
- category_id=None,
page=1,
page_size=10,
search=None,
@@ -131,7 +128,6 @@ def test_list_agent_repository_listings_api_passes_pending_review_status(
"test_tenant_id",
status="pending_review",
agent_id=None,
- category_id=None,
page=1,
page_size=10,
search=None,
@@ -171,7 +167,6 @@ def test_list_agent_repository_listings_api_passes_pagination_and_search(
"test_tenant_id",
status=None,
agent_id=None,
- category_id=None,
page=2,
page_size=6,
search="alpha",
@@ -411,7 +406,6 @@ def test_create_agent_repository_listing_api_passes_card_fields(mocker, mock_aut
payload = {
"icon": "🤖",
- "category_id": 2,
"tags": ["代码审查", "自定义"],
"downloads": 0,
}
diff --git a/test/backend/database/test_agent_repository_db.py b/test/backend/database/test_agent_repository_db.py
index 719508c67..5d876c69d 100644
--- a/test/backend/database/test_agent_repository_db.py
+++ b/test/backend/database/test_agent_repository_db.py
@@ -56,7 +56,6 @@ class _AgentRepositoryModel:
description = MagicMock(name="description")
author = MagicMock(name="author")
submitted_by = MagicMock(name="submitted_by")
- category_id = MagicMock(name="category_id")
tags = MagicMock(name="tags")
tool_count = MagicMock(name="tool_count")
icon = MagicMock(name="icon")
@@ -145,7 +144,6 @@ def __init__(self, **kwargs):
"description": "desc",
"author": "author",
"submitted_by": None,
- "category_id": 1,
"tags": ["tag1"],
"tool_count": 2,
"version_name": "v1",
@@ -172,7 +170,6 @@ def __init__(self, **kwargs):
"display_name": "Test Agent",
"description": "desc",
"status": STATUS_NOT_SHARED,
- "category_id": 1,
"tags": ["tag1"],
"tool_count": 2,
"version_name": "v1",
@@ -545,7 +542,6 @@ def test_list_agent_repository_summaries_returns_expected_shape(monkeypatch, moc
"display_name",
"description",
"status",
- "category_id",
"tags",
"tool_count",
"version_name",
@@ -566,11 +562,10 @@ def test_list_agent_repository_summaries_with_filters(monkeypatch, mock_session)
"tenant-1",
status="shared",
agent_id=10,
- category_id=3,
)
assert result == []
- assert inner_query.filter.call_count == 3
+ assert inner_query.filter.call_count == 2
def test_list_agent_repository_summaries_empty(monkeypatch, mock_session):
diff --git a/test/backend/services/test_agent_repository_service.py b/test/backend/services/test_agent_repository_service.py
index 9ff4c049e..2f36e39c7 100644
--- a/test/backend/services/test_agent_repository_service.py
+++ b/test/backend/services/test_agent_repository_service.py
@@ -171,7 +171,6 @@ def test_list_repository_listings_passes_agent_id_to_db():
publisher_tenant_id="tenant_a",
status="shared",
agent_id=123,
- category_id=None,
)
assert [item["agent_repository_id"] for item in result["items"]] == [1]
assert result["pagination"] == {
@@ -258,33 +257,25 @@ def test_list_repository_listings_paginates_filtered_records():
}
-def test_list_repository_listings_search_matches_author_and_tags():
+def test_list_repository_listings_search_matches_tags():
records = [
{
**_repository_record(agent_repository_id=1, status="shared"),
- "author": "alice@example.com",
- "tags": [],
+ "tags": ["sales"],
},
{
**_repository_record(agent_repository_id=2, status="shared"),
- "author": "bob@example.com",
"tags": ["marketing"],
},
]
with patch.object(ars, "list_agent_repository_summaries", return_value=records):
- by_author = ars.list_agent_repository_listings_impl(
- "tenant_a",
- status="shared",
- search="alice",
- )
by_tag = ars.list_agent_repository_listings_impl(
"tenant_a",
status="shared",
search="marketing",
)
- assert [item["agent_repository_id"] for item in by_author["items"]] == [1]
assert [item["agent_repository_id"] for item in by_tag["items"]] == [2]
@@ -325,24 +316,19 @@ def test_validate_card_fields_requires_structural_values():
with pytest.raises(ValueError, match="icon is required"):
ars._validate_create_payload(base)
- with pytest.raises(ValueError, match="category_id is required"):
- ars._validate_create_payload({**base, "icon": "🤖"})
-
with pytest.raises(ValueError, match="tags is required"):
- ars._validate_create_payload({**base, "icon": "🤖", "category_id": 1})
+ ars._validate_create_payload({**base, "icon": "🤖"})
with pytest.raises(ValueError, match="non-empty string"):
ars._validate_create_payload({
**base,
"icon": " ",
- "category_id": 1,
"tags": ["marketing"],
})
ars._validate_create_payload({
**base,
"icon": "🤖",
- "category_id": 99,
"tags": ["marketing"],
})
@@ -1278,7 +1264,6 @@ def test_count_tools_in_snapshot_invalid_input(snapshot):
async def test_build_repository_data_from_agent_merges_card_fields():
card_fields = {
"icon": "📊",
- "category_id": 3,
"tags": [" 数据 ", "数据", "自定义标签"],
"downloads": 10,
}
@@ -1306,7 +1291,6 @@ async def test_build_repository_data_from_agent_merges_card_fields():
)
assert repository_data["icon"] == "📊"
- assert repository_data["category_id"] == 3
assert repository_data["tags"] == ["数据", "自定义标签"]
assert repository_data["downloads"] == 10
assert repository_data["tool_count"] == 0
@@ -1426,7 +1410,6 @@ async def test_create_agent_repository_listing_impl_success():
"agent_info_json": agent_info_json,
"status": "pending_review",
"icon": "🤖",
- "category_id": 1,
"tags": ["营销"],
}
mock_get_by_agent_id.return_value = None
@@ -1488,7 +1471,6 @@ async def test_create_agent_repository_listing_impl_updates_existing():
"agent_info_json": agent_info_json,
"status": "pending_review",
"icon": "🤖",
- "category_id": 1,
"tags": ["营销"],
"tool_count": 3,
}
@@ -1526,7 +1508,6 @@ async def test_create_agent_repository_listing_impl_updates_existing():
"status": "pending_review",
"icon": "🤖",
"tags": ["营销"],
- "category_id": 1,
"tool_count": 3,
},
)
@@ -1561,7 +1542,6 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version():
"agent_info_json": agent_info_json,
"status": "pending_review",
"icon": "🤖",
- "category_id": 1,
"tags": ["营销"],
}
mock_get_by_agent_id.return_value = None
@@ -1702,7 +1682,6 @@ def test_validate_create_payload_requires_agent_info_json():
"version_no": 1,
"name": "agent_one",
"icon": "🤖",
- "category_id": 1,
"tags": ["营销"],
}
From 1abd895dc6626fe6466b2d0f3863a9cd42441ef8 Mon Sep 17 00:00:00 2001
From: Jinyu Z <1012871848@qq.com>
Date: Thu, 9 Jul 2026 19:53:36 +0800
Subject: [PATCH 14/26] =?UTF-8?q?=F0=9F=90=9BBugfix:=20Fix=20responsive=20?=
=?UTF-8?q?layout=20issues=20on=20knowledge=20base=20page=20(#3381)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 🐛 BugFix: show name conflict error inline in knowledge base input (#3251)
Show error message inline below input when KB name already exists, instead of relying on input status alone. Closes #3251.
- Add nameStatus state + AlertCircle icon + error text
- Change title bar alignment from items-center to items-start
- Pass nameStatus from UploadArea to DocumentList via prop
* 🐛 BugFix: Improve responsive layout of knowledge base creation page (#3251)
* fix: improve responsive layout and prevent overflow in knowledge base components
---
.../knowledges/KnowledgeBaseConfiguration.tsx | 4 +-
.../components/document/DocumentList.tsx | 104 +++++++++---------
.../knowledge/KnowledgeBaseList.tsx | 18 ++-
.../components/upload/UploadArea.tsx | 17 ++-
.../components/upload/UploadAreaUI.tsx | 5 +-
5 files changed, 80 insertions(+), 68 deletions(-)
diff --git a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx
index 26cd438a5..dd3a427e0 100644
--- a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx
+++ b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx
@@ -1013,7 +1013,8 @@ function DataConfig({ isActive }: DataConfigProps) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
-
+
+
+
= {
};
const TITLE_BAR_HEIGHT_CLASS_MAP: Record = {
- "56.8px": "h-[56.8px]",
+ "56.8px": "min-h-[56.8px]",
};
interface DocumentListProps {
@@ -253,6 +255,7 @@ const DocumentListContainer = forwardRef(
}));
const [showDetail, setShowDetail] = React.useState(false);
const [showChunk, setShowChunk] = React.useState(false);
+ const [nameStatus, setNameStatus] = useState("available");
const [summary, setSummary] = useState("");
const [isSummarizing, setIsSummarizing] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -545,60 +548,67 @@ const DocumentListContainer = forwardRef(
const containerHeightClass =
CONTAINER_HEIGHT_CLASS_MAP[containerHeight] ?? "h-full";
const titleBarHeightClass =
- TITLE_BAR_HEIGHT_CLASS_MAP[titleBarHeight] ?? "h-14";
+ TITLE_BAR_HEIGHT_CLASS_MAP[titleBarHeight] ?? "min-h-[56px]";
return (
{/* Title bar */}
-
+
{isCreatingMode ? (
-
-
- onNameChange && onNameChange(e.target.value)
- }
- placeholder={t("document.input.knowledgeBaseName")}
- className={`${LAYOUT.KB_TITLE_MARGIN} w-[240px] font-medium my-[2px]`}
- size="large"
- prefix={📚}
- autoFocus
- disabled={
- hasDocuments || isUploading || docState.isLoadingDocuments
- }
- />
- {/* Right-aligned container for dropdowns */}
+
+
+
+ onNameChange && onNameChange(e.target.value)
+ }
+ placeholder={t("document.input.knowledgeBaseName")}
+ className={`${LAYOUT.KB_TITLE_MARGIN} max-w-[240px] font-medium`}
+ size="large"
+ prefix={📚}
+ status={
+ isCreatingMode &&
+ (nameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT ||
+ nameStatus === NAME_CHECK_STATUS.EXISTS_IN_OTHER_TENANT)
+ ? "error"
+ : undefined
+ }
+ autoFocus
+ disabled={
+ hasDocuments || isUploading || docState.isLoadingDocuments
+ }
+ />
+ {isCreatingMode &&
+ (nameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT ||
+ nameStatus === NAME_CHECK_STATUS.EXISTS_IN_OTHER_TENANT) && (
+
+
+
+ {t("tenantResources.knowledgeBase.nameExists")}
+
+
+ )}
+
+ {/* Right dropdowns for create mode */}
{/* Embedding model selection - first position in create mode */}
{isCreatingMode && onEmbeddingModelChange && (
) : (
{knowledgeBaseName}
@@ -705,7 +708,7 @@ const DocumentListContainer = forwardRef(
{/* Right: overview and detail buttons */}
{!isCreatingMode && !isDataMate && (
-
+
}
@@ -1107,6 +1110,7 @@ const DocumentListContainer = forwardRef(
indexName={knowledgeBaseId || knowledgeBaseName}
newKnowledgeBaseName={isCreatingMode ? knowledgeBaseName : ""}
modelMismatch={modelMismatch}
+ onNameStatusChange={setNameStatus}
/>
))}
diff --git a/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx b/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx
index 53758147b..cbe616c03 100644
--- a/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx
+++ b/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx
@@ -319,7 +319,7 @@ const KnowledgeBaseList: React.FC = ({
]);
return (
-
+
{/* Fixed header area */}
= ({
{t("knowledgeBase.list.title")}
-
+
= ({
backgroundColor: "#1677ff",
color: "white",
border: "none",
- overflow: "hidden",
- whiteSpace: "nowrap",
- minWidth: 0,
+ flexShrink: 0,
}}
className="hover:!bg-blue-600"
type="primary"
onClick={onDataMateConfig}
icon={ }
>
-
+
{t("knowledgeBase.button.dataMateConfig")}
@@ -409,13 +407,13 @@ const KnowledgeBaseList: React.FC = ({
{/* Search and filter area */}
-
+
}
value={effectiveSearchKeyword}
onChange={(e) => handleSearchChange(e.target.value)}
- style={{ width: 250 }}
+ className="flex-1 min-w-0"
allowClear
/>
@@ -425,7 +423,7 @@ const KnowledgeBaseList: React.FC = ({
placeholder={t("knowledgeBase.filter.source.placeholder")}
value={effectiveSelectedSources}
onChange={handleSourcesChange}
- style={{ minWidth: 150 }}
+ className="flex-1 min-w-0"
allowClear
maxTagCount={2}
>
@@ -445,7 +443,7 @@ const KnowledgeBaseList: React.FC = ({
placeholder={t("knowledgeBase.filter.model.placeholder")}
value={effectiveSelectedModels}
onChange={handleModelsChange}
- style={{ minWidth: 180 }}
+ className="flex-1 min-w-0"
allowClear
maxTagCount={2}
>
diff --git a/frontend/app/[locale]/knowledges/components/upload/UploadArea.tsx b/frontend/app/[locale]/knowledges/components/upload/UploadArea.tsx
index e92017369..03c4c1d78 100644
--- a/frontend/app/[locale]/knowledges/components/upload/UploadArea.tsx
+++ b/frontend/app/[locale]/knowledges/components/upload/UploadArea.tsx
@@ -29,6 +29,7 @@ interface UploadAreaProps {
indexName?: string;
newKnowledgeBaseName?: string;
modelMismatch?: boolean;
+ onNameStatusChange?: (status: string) => void;
}
export interface UploadAreaRef {
@@ -48,6 +49,7 @@ const UploadArea = forwardRef(
newKnowledgeBaseName = "",
selectedFiles = [],
modelMismatch = false,
+ onNameStatusChange,
},
ref
) => {
@@ -65,13 +67,18 @@ const UploadArea = forwardRef(
prevFileListRef.current = fileList;
}, [fileList]);
+ const updateNameStatus = useCallback((status: string) => {
+ setNameStatus(status);
+ onNameStatusChange?.(status);
+ }, [onNameStatusChange]);
+
// Function to reset all states
const resetAllStates = useCallback(() => {
setFileList([]);
- setNameStatus("available");
+ updateNameStatus("available");
setIsLoading(true);
setIsKnowledgeBaseReady(false);
- }, []);
+ }, [updateNameStatus]);
// Listen for knowledge base changes, reset file list and get knowledge base info
useEffect(() => {
@@ -140,17 +147,17 @@ const UploadArea = forwardRef(
// Check if knowledge base name already exists
useEffect(() => {
if (!isCreatingMode || !newKnowledgeBaseName) {
- setNameStatus("available");
+ updateNameStatus("available");
return;
}
const checkName = async () => {
try {
const result = await checkKnowledgeBaseName(newKnowledgeBaseName, t);
- setNameStatus(result.status);
+ updateNameStatus(result.status);
} catch (error) {
log.error(t("knowledgeBase.error.checkName"), error);
- setNameStatus(NAME_CHECK_STATUS.CHECK_FAILED); // Handle check failure
+ updateNameStatus(NAME_CHECK_STATUS.CHECK_FAILED); // Handle check failure
}
};
diff --git a/frontend/app/[locale]/knowledges/components/upload/UploadAreaUI.tsx b/frontend/app/[locale]/knowledges/components/upload/UploadAreaUI.tsx
index 93982236b..111d086f3 100644
--- a/frontend/app/[locale]/knowledges/components/upload/UploadAreaUI.tsx
+++ b/frontend/app/[locale]/knowledges/components/upload/UploadAreaUI.tsx
@@ -169,13 +169,14 @@ const UploadAreaUI: React.FC = ({
{...uploadProps}
className="!h-full flex flex-col justify-center !bg-transparent !border-gray-200"
showUploadList={false}
+ style={{ height: "100%", overflow: "auto" }}
>
-
+
- {t("knowledgeBase.upload.dragHint")}
+ {t("knowledgeBase.upload.dragHint")}
{t("knowledgeBase.upload.supportedFormats")}
From 5a6fce7342131d76b3b3a8183996d7af53a09f36 Mon Sep 17 00:00:00 2001
From: panyehong <91180085+YehongPan@users.noreply.github.com>
Date: Fri, 10 Jul 2026 09:36:03 +0800
Subject: [PATCH 15/26] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20Fixed=20an=20iss?=
=?UTF-8?q?ue=20with=20tools=20relying=20on=20large=20models:=20previously?=
=?UTF-8?q?,=20if=20a=20model=20was=20selected=20and=20subsequently=20dele?=
=?UTF-8?q?ted,=20the=20agent=20would=20encounter=20runtime=20errors=20wit?=
=?UTF-8?q?hout=20any=20corresponding=20notification=20on=20the=20frontend?=
=?UTF-8?q?.=20(#3397)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 🐛 Bugfix: Fixed an issue where deleted models could still be selected during debugging. + Internationalization loading description of tools.
* 🐛 Bugfix: Fixed an issue with tools relying on large models: previously, if a model was selected and subsequently deleted, the agent would encounter runtime errors without any corresponding notification on the frontend.
---
backend/consts/agent_unavailable_reasons.py | 2 +
backend/database/model_management_db.py | 70 +-
backend/services/agent_service.py | 64 +-
backend/services/agent_version_service.py | 7 +-
.../components/agentConfig/ToolManagement.tsx | 14 +-
.../agentConfig/tool/SelectToolsDialog.tsx | 149 ++-
frontend/public/locales/en/common.json | 5 +-
frontend/public/locales/zh/common.json | 5 +-
frontend/services/agentConfigService.ts | 3 +
frontend/types/agentConfig.ts | 5 +
.../database/test_model_managment_db.py | 261 +++++
test/backend/services/test_agent_service.py | 925 +++++++++++++++++-
.../services/test_agent_version_service.py | 182 ++++
13 files changed, 1591 insertions(+), 101 deletions(-)
diff --git a/backend/consts/agent_unavailable_reasons.py b/backend/consts/agent_unavailable_reasons.py
index 4e710ee7d..04d1230c3 100644
--- a/backend/consts/agent_unavailable_reasons.py
+++ b/backend/consts/agent_unavailable_reasons.py
@@ -20,6 +20,7 @@ class AgentUnavailableReason:
# Tool issues
TOOL_UNAVAILABLE = "tool_unavailable"
ALL_TOOLS_DISABLED = "all_tools_disabled"
+ MCP_MODEL_UNAVAILABLE = "mcp_model_unavailable"
# Agent issues
AGENT_NOT_FOUND = "agent_not_found"
@@ -35,6 +36,7 @@ def all_reasons(cls) -> list[str]:
cls.TOOL_UNAVAILABLE,
cls.ALL_TOOLS_DISABLED,
cls.AGENT_NOT_FOUND,
+ cls.MCP_MODEL_UNAVAILABLE,
]
@classmethod
diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py
index 1a1a98c8b..64b00b9f3 100644
--- a/backend/database/model_management_db.py
+++ b/backend/database/model_management_db.py
@@ -182,7 +182,7 @@ def get_model_by_display_name(display_name: str, tenant_id: str, model_type: str
tenant_id:
"""
filters = {'display_name': display_name}
-
+
if model_type in ["multiEmbedding", "multi_embedding"]:
filters['model_type'] = "multi_embedding"
elif model_type == "embedding":
@@ -216,7 +216,7 @@ def get_model_id_by_display_name(display_name: str, tenant_id: str, model_type:
Get a model ID by display name
Args:
- display_name: Model display name
+ display_name: Model display name
tenant_id: tenant_id
Returns:
@@ -228,7 +228,7 @@ def get_model_id_by_display_name(display_name: str, tenant_id: str, model_type:
def get_model_by_model_id(model_id: int, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
- Get a model record using native SQLAlchemy query
+ Get a model record using native SQLAlchemy query.
Args:
model_id (int): Model ID
@@ -269,6 +269,37 @@ def get_model_by_model_id(model_id: int, tenant_id: Optional[str] = None) -> Opt
return result_dict
+def get_model_by_model_id_ignore_delete(model_id: int, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ """
+ Get a model record without filtering by delete_flag.
+
+ Used when checking whether a previously selected model has been deleted
+ (e.g., for tools that store a selected_model_id in their params).
+
+ Args:
+ model_id (int): Model ID
+ tenant_id (Optional[str]): Tenant ID, optional
+
+ Returns:
+ Optional[Dict[str, Any]]: Model record as a dictionary, or None if not found
+ """
+ with get_db_session() as session:
+ stmt = select(ModelRecord).where(ModelRecord.model_id == model_id)
+ if tenant_id:
+ stmt = stmt.where(ModelRecord.tenant_id == tenant_id)
+ result = session.scalars(stmt).first()
+ if result is None:
+ return None
+ result_dict = {key: value for key,
+ value in result.__dict__.items() if not key.startswith('_')}
+ if result_dict.get("model_type") in ["embedding", "multi_embedding"]:
+ if result_dict.get("expected_chunk_size") is None:
+ result_dict["expected_chunk_size"] = DEFAULT_EXPECTED_CHUNK_SIZE
+ if result_dict.get("maximum_chunk_size") is None:
+ result_dict["maximum_chunk_size"] = DEFAULT_MAXIMUM_CHUNK_SIZE
+ return result_dict
+
+
def get_models_by_tenant_factory_type(tenant_id: str, model_factory: str, model_type: str) -> List[Dict[str, Any]]:
"""
Get all model database records matching tenant_id, model_factory, and model_type.
@@ -280,15 +311,44 @@ def get_models_by_tenant_factory_type(tenant_id: str, model_factory: str, model_
return get_model_records(filters, tenant_id)
+def get_valid_model_ids(model_ids: List[int], tenant_id: str) -> List[int]:
+ """
+ Filter model IDs to only include those that are not soft-deleted (delete_flag='N').
+
+ When a model is deleted from model management, its delete_flag is set to 'Y'.
+ This function ensures that only valid (non-deleted) model IDs are returned,
+ preserving the order of the input model_ids.
+
+ Args:
+ model_ids: List of model IDs to filter
+ tenant_id: Tenant ID for filtering
+
+ Returns:
+ List[int]: Filtered list of valid model IDs in original order
+ """
+ if not model_ids:
+ return []
+
+ with get_db_session() as session:
+ stmt = select(ModelRecord.model_id).where(
+ ModelRecord.model_id.in_(model_ids),
+ ModelRecord.delete_flag == 'N'
+ )
+ valid_ids = session.scalars(stmt).all()
+ valid_ids_set = set(valid_ids)
+
+ return [mid for mid in model_ids if mid in valid_ids_set]
+
+
def get_model_by_name_factory(model_name: str, model_factory: str, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get a model record by model_name and model_factory for deduplication.
-
+
Args:
model_name: Model name (e.g., "deepseek-r1-distill-qwen-14b")
model_factory: Model factory (e.g., "ModelEngine")
tenant_id: Tenant ID
-
+
Returns:
Optional[Dict[str, Any]]: Model record if found, None otherwise
"""
diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py
index 5ba8f548a..c027cabaa 100644
--- a/backend/services/agent_service.py
+++ b/backend/services/agent_service.py
@@ -62,7 +62,7 @@
clear_agent_new_mark
)
from database import a2a_agent_db
-from database.model_management_db import get_model_by_model_id, get_model_id_by_display_name
+from database.model_management_db import get_model_by_model_id, get_model_by_model_id_ignore_delete, get_model_id_by_display_name, get_valid_model_ids
from database.remote_mcp_db import get_mcp_server_by_name_and_tenant
from database.tool_db import (
check_tool_is_available,
@@ -1376,6 +1376,22 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0
try:
tool_info = search_tools_for_sub_agent(
agent_id=agent_id, tenant_id=tenant_id)
+ # Check if selected_model_id in tool params points to a deleted model
+ for tool in tool_info:
+ unavailable_reasons: List[str] = []
+ params = tool.get("params") or []
+ if isinstance(params, list):
+ for param_def in params:
+ if not isinstance(param_def, dict):
+ continue
+ if param_def.get("name") == "selected_model_id":
+ selected_model_id = param_def.get("default")
+ if selected_model_id is not None:
+ model_record = get_model_by_model_id_ignore_delete(selected_model_id, tenant_id)
+ if model_record is not None and model_record.get("delete_flag") == "Y":
+ unavailable_reasons.append(AgentUnavailableReason.MCP_MODEL_UNAVAILABLE)
+ break
+ tool["unavailable_reasons"] = unavailable_reasons
agent_info["tools"] = tool_info
except Exception as e:
logger.error(f"Failed to get agent tools: {str(e)}")
@@ -1412,19 +1428,22 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0
agent_info["external_sub_agent_id_list"] = []
# Get model names from model_ids array
- model_ids = agent_info.get("model_ids")
+ # Filter out deleted models (delete_flag='Y' in model_record_t)
+ model_ids = agent_info.get("model_ids") or []
+ valid_model_ids = get_valid_model_ids(model_ids, tenant_id)
+ agent_info["model_ids"] = valid_model_ids
+
model_names: List[str] = []
- if model_ids and len(model_ids) > 0:
- for mid in model_ids:
- model_info = get_model_by_model_id(mid)
- if model_info:
- display_name = model_info.get("display_name")
- if display_name:
- model_names.append(display_name)
+ for mid in valid_model_ids:
+ model_info = get_model_by_model_id(mid)
+ if model_info:
+ display_name = model_info.get("display_name")
+ if display_name:
+ model_names.append(display_name)
agent_info["model_names"] = model_names
- # Always derive model_name from model_ids so the API contract is consistent.
- if model_ids and len(model_ids) > 0:
- first_model_info = get_model_by_model_id(model_ids[0])
+ # Always derive model_name from valid_model_ids so the API contract is consistent.
+ if valid_model_ids:
+ first_model_info = get_model_by_model_id(valid_model_ids[0])
agent_info["model_name"] = first_model_info.get(
"display_name", None) if first_model_info is not None else None
else:
@@ -2383,6 +2402,11 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]:
if not is_creator and (len(user_group_ids.intersection(agent_group_ids)) == 0 or ingroup_permission == PERMISSION_PRIVATE):
continue
+ # Filter out deleted models (delete_flag='Y' in model_record_t)
+ raw_model_ids = agent.get("model_ids") or []
+ valid_model_ids = get_valid_model_ids(raw_model_ids, tenant_id)
+ agent["model_ids"] = valid_model_ids
+
# Use shared availability check function
_, unavailable_reasons = check_agent_availability(
agent_id=agent["agent_id"],
@@ -2579,6 +2603,22 @@ def check_agent_availability(
if not all(tool_statuses):
unavailable_reasons.append(AgentUnavailableReason.TOOL_UNAVAILABLE)
+ # Check if any tool has a selected_model_id pointing to a deleted model
+ for tool in tool_info:
+ params = tool.get("params") or []
+ if isinstance(params, list):
+ for param_def in params:
+ if not isinstance(param_def, dict):
+ continue
+ if param_def.get("name") == "selected_model_id":
+ selected_model_id = param_def.get("default")
+ if selected_model_id is not None:
+ model_record = get_model_by_model_id_ignore_delete(
+ selected_model_id, tenant_id)
+ if model_record is not None and model_record.get("delete_flag") == "Y":
+ unavailable_reasons.append(AgentUnavailableReason.TOOL_UNAVAILABLE)
+ break
+
# Check model availability
model_reasons = _collect_model_availability_reasons(
agent=agent_info,
diff --git a/backend/services/agent_version_service.py b/backend/services/agent_version_service.py
index 0398ce3f5..1bc91d333 100644
--- a/backend/services/agent_version_service.py
+++ b/backend/services/agent_version_service.py
@@ -31,7 +31,7 @@
STATUS_DISABLED,
STATUS_ARCHIVED,
)
-from database.model_management_db import get_model_by_model_id
+from database.model_management_db import get_model_by_model_id, get_valid_model_ids
from utils.str_utils import convert_string_to_list
from consts.agent_unavailable_reasons import AgentUnavailableReason
@@ -899,6 +899,11 @@ async def list_published_agents_impl(
# Add current version info
agent_info['current_version_no'] = current_version_no
+ # Filter out deleted models (delete_flag='Y' in model_record_t)
+ raw_model_ids = agent_info.get("model_ids") or []
+ valid_model_ids = get_valid_model_ids(raw_model_ids, tenant_id)
+ agent_info["model_ids"] = valid_model_ids
+
# Check agent availability using the shared function
_, unavailable_reasons = check_agent_availability(
agent_id=agent_id,
diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx
index 5f97ef668..9744cd95f 100644
--- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx
+++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx
@@ -186,15 +186,18 @@ export default function ToolManagement({ isCreatingMode, currentAgentId }: ToolM
{cat.tools.map((tool) => {
const labels = getToolLabels(tool);
+ const toolUnavailableReasons = tool.unavailable_reasons || [];
+ const isModelUnavailable = toolUnavailableReasons.includes("mcp_model_unavailable");
const disabled =
isToolDisabledDueToVlm(tool.name, isImageUnderstandingAvailable, isVideoUnderstandingAvailable) ||
- isToolDisabledDueToEmbedding(tool.name, isEmbeddingAvailable);
+ isToolDisabledDueToEmbedding(tool.name, isEmbeddingAvailable) ||
+ isModelUnavailable;
return (
-
+
{tool.name}
{labels.slice(0, 2).map((l) => (
@@ -209,7 +212,12 @@ export default function ToolManagement({ isCreatingMode, currentAgentId }: ToolM
)}
- {disabled && }
+ {isModelUnavailable && (
+
+
+
+ )}
+ {disabled && !isModelUnavailable && }
diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx
index 1561d20de..acb54fcc0 100644
--- a/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx
+++ b/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx
@@ -2,9 +2,10 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
-import { Modal, Tabs, Input, Checkbox, Button, Select } from "antd";
+import { Modal, Tabs, Input, Checkbox, Button, Select, Tooltip } from "antd";
import type { TabsProps } from "antd";
import { Search, Settings, Wrench, Tag } from "lucide-react";
+import i18n from "i18next";
import { useToolList } from "@/hooks/agent/useToolList";
import { useAgentConfigStore } from "@/stores/agentConfigStore";
@@ -31,6 +32,27 @@ function isToolDisabled(name: string, img: boolean, vid: boolean, emb: boolean):
return false;
}
+function getToolDisabledTooltipKey(name: string, img: boolean, vid: boolean, emb: boolean): string | null {
+ if (TOOLS_REQUIRING_IMAGE_UNDERSTANDING.includes(name) && !img) {
+ return "toolPool.imageUnderstandingDisabledTooltip";
+ }
+ if (TOOLS_REQUIRING_VIDEO_UNDERSTANDING.includes(name) && !vid) {
+ return "toolPool.videoUnderstandingDisabledTooltip";
+ }
+ if (TOOLS_REQUIRING_EMBEDDING.includes(name) && !emb) {
+ return "toolPool.embeddingDisabledTooltip";
+ }
+ return null;
+}
+
+function getToolDescription(tool: any): string {
+ const locale = i18n.language || "en";
+ if (locale === "zh" && tool.description_zh) {
+ return tool.description_zh;
+ }
+ return tool.description || "";
+}
+
const SOURCE_TABS: { key: string; labelKey: string; sourceValue: string }[] = [
{ key: "local", labelKey: "toolPool.group.local", sourceValue: TOOL_SOURCE_TYPES.LOCAL },
{ key: "mcp", labelKey: "toolPool.group.mcp", sourceValue: TOOL_SOURCE_TYPES.MCP },
@@ -124,11 +146,12 @@ export default function SelectToolsDialog({
if (!hasSearch && !hasLabels) return groups;
const filterOne = (tool: any): boolean => {
- // Search filter (OR across name/desc/tags)
+ // Search filter (OR across name/desc/desc_zh/tags)
if (hasSearch) {
+ const toolDesc = getToolDescription(tool);
const matchSearch =
tool.name.toLowerCase().includes(kw) ||
- (tool.description && tool.description.toLowerCase().includes(kw)) ||
+ (toolDesc && toolDesc.toLowerCase().includes(kw)) ||
getToolLabels(tool).some((l: string) => l.toLowerCase().includes(kw));
if (!matchSearch) return false;
}
@@ -396,60 +419,78 @@ export default function SelectToolsDialog({
isVideoUnderstandingAvailable,
isEmbeddingAvailable
);
-
- return (
-
- handleToolToggle(tool)}
- onKeyDown={(e) => {
- if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
- e.preventDefault();
- handleToolToggle(tool);
- }
- }}
- >
-
-
-
-
- {tool.name}
-
- {getToolLabels(tool)
- .slice(0, 2)
- .map((label: string) => (
-
- {label}
-
- ))}
-
- {tool.description && (
-
- {tool.description}
-
- )}
+ const disabledTooltipKey = disabled
+ ? getToolDisabledTooltipKey(
+ tool.name,
+ isImageUnderstandingAvailable,
+ isVideoUnderstandingAvailable,
+ isEmbeddingAvailable
+ )
+ : null;
+
+ const row = (
+ handleToolToggle(tool)}
+ onKeyDown={(e) => {
+ if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ handleToolToggle(tool);
+ }
+ }}
+ >
+
+
+
+
+ {tool.name}
+
+ {getToolLabels(tool)
+ .slice(0, 2)
+ .map((label: string) => (
+
+ {label}
+
+ ))}
- {!disabled && (
- {
- e.stopPropagation();
- openConfigModal(tool);
- }}
- className="flex size-7 shrink-0 items-center justify-center rounded-md text-gray-400 opacity-0 transition-opacity hover:bg-gray-100 hover:text-gray-600 group-hover:opacity-100"
- >
-
-
+ {tool.description && (
+
+ {getToolDescription(tool)}
+
)}
+ {!disabled && (
+ {
+ e.stopPropagation();
+ openConfigModal(tool);
+ }}
+ className="flex size-7 shrink-0 items-center justify-center rounded-md text-gray-400 opacity-0 transition-opacity hover:bg-gray-100 hover:text-gray-600 group-hover:opacity-100"
+ >
+
+
+ )}
+
+ );
+
+ return (
+
+ {disabledTooltipKey ? (
+
+ {row}
+
+ ) : (
+ row
+ )}
);
})}
diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index 5ecebc99b..097445775 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -1,4 +1,4 @@
-{
+{
"assistant.name": "Nexent",
"mainPage.layout.title": "Nexent | AI Agents",
@@ -590,6 +590,8 @@
"toolPool.error.requiredFields": "The following required fields are not filled: {{fields}}",
"toolPool.vlmRequired": "VLM model required",
"toolPool.vlmDisabledTooltip": "Please contact your administrator to configure an available Vision Language Model",
+ "toolPool.imageUnderstandingDisabledTooltip": "Please contact your administrator to configure an available Image Understanding model",
+ "toolPool.videoUnderstandingDisabledTooltip": "Please contact your administrator to configure an available Video Understanding model",
"toolPool.embeddingDisabledTooltip": "Please contact your administrator to configure an available Embedding model",
"toolPool.tooltip.functionGuide": "1. For local knowledge base search functionality, please enable the knowledge_base_search tool;\n2. For text file parsing functionality, please enable the analyze_text_file tool;\n3. For image parsing functionality, please enable the analyze_image tool.",
"toolPool.duplicateToolName.title": "Duplicate Tool Name Detected",
@@ -603,6 +605,7 @@
"toolPool.remove": "Remove",
"toolPool.selectedToolsLabel": "Selected Tools",
"toolPool.noSearchResults": "No tools match your search",
+ "toolPool.mcpModelUnavailableTooltip": "Selected model is unavailable",
"tool.message.unavailable": "This tool is currently unavailable and cannot be selected",
"tool.error.noMainAgentId": "Main Agent ID is not set, cannot update tool status",
diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json
index 4937946b3..bc916a847 100644
--- a/frontend/public/locales/zh/common.json
+++ b/frontend/public/locales/zh/common.json
@@ -1,4 +1,4 @@
-{
+{
"assistant.name": "Nexent",
"mainPage.layout.title": "Nexent | 智能问答",
@@ -563,6 +563,8 @@
"toolPool.error.requiredFields": "以下必填字段未填写: {{fields}}",
"toolPool.vlmRequired": "需要配置视觉语言模型",
"toolPool.vlmDisabledTooltip": "请联系管理员配置可用的视觉语言模型",
+ "toolPool.imageUnderstandingDisabledTooltip": "请联系管理员配置可用的图片理解模型",
+ "toolPool.videoUnderstandingDisabledTooltip": "请联系管理员配置可用的视频理解模型",
"toolPool.embeddingDisabledTooltip": "请联系管理员配置可用的向量模型",
"toolPool.tooltip.functionGuide": "1. 本地知识库检索功能,请启用knowledge_base_search工具;\n2. 文本文件解析功能,请启用analyze_text_file工具;\n3. 图片解析功能,请启用analyze_image工具。",
"toolPool.duplicateToolName.title": "检测到重复工具名",
@@ -576,6 +578,7 @@
"toolPool.remove": "移除",
"toolPool.selectedToolsLabel": "已选择工具",
"toolPool.noSearchResults": "没有搜索到匹配的工具",
+ "toolPool.mcpModelUnavailableTooltip": "工具所选模型不可用",
"tool.message.unavailable": "该工具当前不可用,无法选择",
"tool.error.noMainAgentId": "主代理ID未设置,无法更新工具状态",
diff --git a/frontend/services/agentConfigService.ts b/frontend/services/agentConfigService.ts
index 7d09b79be..03e80cfcb 100644
--- a/frontend/services/agentConfigService.ts
+++ b/frontend/services/agentConfigService.ts
@@ -806,6 +806,9 @@ export const searchAgentInfo = async (
is_available: tool.is_available,
usage: tool.usage,
category: tool.category,
+ unavailable_reasons: Array.isArray(tool.unavailable_reasons)
+ ? tool.unavailable_reasons
+ : [],
// Pass through `inputs` so the ToolTestPanel can parse runtime
// input parameters when reopening a tool from the selected
// tools list. Without this, the test panel falls back to
diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts
index 6b2ce6098..5a30b8bea 100644
--- a/frontend/types/agentConfig.ts
+++ b/frontend/types/agentConfig.ts
@@ -136,6 +136,11 @@ export interface Tool {
* Used to pass knowledge base names to prompt generation without requiring database lookup.
*/
display_names?: string[];
+ /**
+ * Reasons why this tool may be unavailable.
+ * E.g., ["mcp_model_unavailable"] when the selected model has been deleted.
+ */
+ unavailable_reasons?: string[];
}
export interface ToolParam {
diff --git a/test/backend/database/test_model_managment_db.py b/test/backend/database/test_model_managment_db.py
index f0fbcfe7c..25692c1ca 100644
--- a/test/backend/database/test_model_managment_db.py
+++ b/test/backend/database/test_model_managment_db.py
@@ -447,3 +447,264 @@ def test_get_model_by_model_id_not_found(monkeypatch):
mock_ctx.__exit__.return_value = None
monkeypatch.setattr("backend.database.model_management_db.get_db_session", lambda: mock_ctx)
assert model_mgmt_db.get_model_by_model_id(999, tenant_id="t") is None
+
+
+def test_get_valid_model_ids_empty_input(monkeypatch):
+ """Test get_valid_model_ids with empty input returns empty list."""
+ result = model_mgmt_db.get_valid_model_ids([], "tenant1")
+ assert result == []
+
+
+def test_get_valid_model_ids_all_valid(monkeypatch):
+ """Test get_valid_model_ids when all model IDs are valid (not deleted)."""
+ # Mock session.scalars().all() to return all model IDs
+ mock_scalars = MagicMock()
+ mock_scalars.all.return_value = [1, 2, 3]
+ session = MagicMock()
+ session.scalars.return_value = mock_scalars
+
+ mock_ctx = MagicMock()
+ mock_ctx.__enter__.return_value = session
+ mock_ctx.__exit__.return_value = None
+ monkeypatch.setattr("backend.database.model_management_db.get_db_session", lambda: mock_ctx)
+
+ # All model IDs are valid
+ result = model_mgmt_db.get_valid_model_ids([1, 2, 3], "tenant1")
+ assert result == [1, 2, 3]
+
+
+def test_get_valid_model_ids_some_deleted(monkeypatch):
+ """Test get_valid_model_ids filters out deleted models."""
+ # Mock session.scalars().all() to return only valid model IDs (1 and 3, 2 is deleted)
+ mock_scalars = MagicMock()
+ mock_scalars.all.return_value = [1, 3]
+ session = MagicMock()
+ session.scalars.return_value = mock_scalars
+
+ mock_ctx = MagicMock()
+ mock_ctx.__enter__.return_value = session
+ mock_ctx.__exit__.return_value = None
+ monkeypatch.setattr("backend.database.model_management_db.get_db_session", lambda: mock_ctx)
+
+ # Model 2 was deleted (delete_flag='Y'), so only 1 and 3 should be returned
+ result = model_mgmt_db.get_valid_model_ids([1, 2, 3], "tenant1")
+ assert result == [1, 3]
+
+
+def test_get_valid_model_ids_preserves_order(monkeypatch):
+ """Test get_valid_model_ids preserves the order of valid model IDs."""
+ # Mock session.scalars().all() to return valid model IDs in arbitrary order from DB
+ mock_scalars = MagicMock()
+ mock_scalars.all.return_value = [3, 1] # DB returns in different order
+ session = MagicMock()
+ session.scalars.return_value = mock_scalars
+
+ mock_ctx = MagicMock()
+ mock_ctx.__enter__.return_value = session
+ mock_ctx.__exit__.return_value = None
+ monkeypatch.setattr("backend.database.model_management_db.get_db_session", lambda: mock_ctx)
+
+ # Original order should be preserved for valid IDs
+ result = model_mgmt_db.get_valid_model_ids([2, 3, 1], "tenant1")
+ assert result == [3, 1] # Only valid ones in original order
+
+
+def test_get_valid_model_ids_none_deleted(monkeypatch):
+ """Test get_valid_model_ids when all models are deleted."""
+ # Mock session.scalars().all() to return empty list (all deleted)
+ mock_scalars = MagicMock()
+ mock_scalars.all.return_value = []
+ session = MagicMock()
+ session.scalars.return_value = mock_scalars
+
+ mock_ctx = MagicMock()
+ mock_ctx.__enter__.return_value = session
+ mock_ctx.__exit__.return_value = None
+ monkeypatch.setattr("backend.database.model_management_db.get_db_session", lambda: mock_ctx)
+
+ # All models were deleted
+ result = model_mgmt_db.get_valid_model_ids([1, 2, 3], "tenant1")
+ assert result == []
+
+
+# ---------------------------------------------------------------------------
+# Tests for get_model_by_model_id_ignore_delete
+# ---------------------------------------------------------------------------
+
+
+def _patch_session(monkeypatch, scalars_first, scalars_all=None):
+ """Helper to install a fake DB session returning the given scalars."""
+ mock_scalars = MagicMock()
+ mock_scalars.first.return_value = scalars_first
+ if scalars_all is not None:
+ mock_scalars.all.return_value = scalars_all
+ session = MagicMock()
+ session.scalars.return_value = mock_scalars
+ ctx = MagicMock()
+ ctx.__enter__.return_value = session
+ ctx.__exit__.return_value = None
+ monkeypatch.setattr(
+ "backend.database.model_management_db.get_db_session", lambda: ctx)
+ return session
+
+
+def test_get_model_by_model_id_ignore_delete_returns_chat_model(monkeypatch):
+ """Non-embedding records should be returned untouched, without chunk-size defaults."""
+ mock_model = SimpleNamespace(
+ model_id=101,
+ model_factory="openai",
+ model_type="chat",
+ tenant_id="tenant_ignore_chat",
+ delete_flag="Y", # explicitly soft-deleted; the function must still return it
+ expected_chunk_size=None,
+ maximum_chunk_size=None,
+ _sa_instance_state=None, # underscore-prefixed; should be dropped from dict
+ )
+ session = _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 101, tenant_id="tenant_ignore_chat")
+
+ # The filtered-by-underscore dictionary must contain only the public attributes.
+ assert result["model_id"] == 101
+ assert result["model_type"] == "chat"
+ assert result["delete_flag"] == "Y"
+ # No default chunk-size backfill for non-embedding types.
+ assert result["expected_chunk_size"] is None
+ assert result["maximum_chunk_size"] is None
+ # Private attributes beginning with "_" are removed from the dict.
+ assert all(not key.startswith("_") for key in result)
+
+
+def test_get_model_by_model_id_ignore_delete_embedding_fills_defaults(monkeypatch):
+ """For embedding records with None chunk sizes, defaults must be applied."""
+ mock_model = SimpleNamespace(
+ model_id=102,
+ model_factory="openai",
+ model_type="embedding",
+ tenant_id="tenant_ignore_emb",
+ delete_flag="Y",
+ expected_chunk_size=None,
+ maximum_chunk_size=None,
+ )
+ _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 102, tenant_id="tenant_ignore_emb")
+
+ assert result["model_type"] == "embedding"
+ assert result["expected_chunk_size"] == 1024
+ assert result["maximum_chunk_size"] == 1536
+
+
+def test_get_model_by_model_id_ignore_delete_multi_embedding_fills_defaults(monkeypatch):
+ """For multi_embedding records with None chunk sizes, defaults must also be applied."""
+ mock_model = SimpleNamespace(
+ model_id=103,
+ model_factory="openai",
+ model_type="multi_embedding",
+ tenant_id="tenant_ignore_multi",
+ delete_flag="Y",
+ expected_chunk_size=None,
+ maximum_chunk_size=None,
+ )
+ _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 103, tenant_id="tenant_ignore_multi")
+
+ assert result["model_type"] == "multi_embedding"
+ assert result["expected_chunk_size"] == 1024
+ assert result["maximum_chunk_size"] == 1536
+
+
+def test_get_model_by_model_id_ignore_delete_embedding_keeps_existing_sizes(monkeypatch):
+ """Pre-existing chunk sizes (even for embedding) must NOT be overwritten."""
+ mock_model = SimpleNamespace(
+ model_id=104,
+ model_factory="openai",
+ model_type="embedding",
+ tenant_id="tenant_ignore_keep",
+ delete_flag="Y",
+ expected_chunk_size=256,
+ maximum_chunk_size=512,
+ )
+ _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 104, tenant_id="tenant_ignore_keep")
+
+ # The pre-existing sizes must be preserved.
+ assert result["expected_chunk_size"] == 256
+ assert result["maximum_chunk_size"] == 512
+
+
+def test_get_model_by_model_id_ignore_delete_embedding_only_one_size_missing(monkeypatch):
+ """When only one of the chunk sizes is missing, only that one should be defaulted."""
+ mock_model = SimpleNamespace(
+ model_id=105,
+ model_factory="openai",
+ model_type="embedding",
+ tenant_id="tenant_ignore_partial",
+ delete_flag="Y",
+ expected_chunk_size=256, # already set
+ maximum_chunk_size=None, # missing — must be defaulted
+ )
+ _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 105, tenant_id="tenant_ignore_partial")
+
+ assert result["expected_chunk_size"] == 256
+ assert result["maximum_chunk_size"] == 1536
+
+
+def test_get_model_by_model_id_ignore_delete_embedding_fills_only_max_if_min_present(monkeypatch):
+ """Complement of the partial-fill test: missing `expected_chunk_size` defaults
+ even though `maximum_chunk_size` is supplied."""
+ mock_model = SimpleNamespace(
+ model_id=106,
+ model_factory="openai",
+ model_type="embedding",
+ tenant_id="tenant_ignore_min",
+ delete_flag="Y",
+ expected_chunk_size=None,
+ maximum_chunk_size=999,
+ )
+ _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 106, tenant_id="tenant_ignore_min")
+
+ assert result["expected_chunk_size"] == 1024
+ assert result["maximum_chunk_size"] == 999
+
+
+def test_get_model_by_model_id_ignore_delete_not_found_returns_none(monkeypatch):
+ """When the DB query finds nothing, the function returns None."""
+ _patch_session(monkeypatch, scalars_first=None)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(
+ 9999, tenant_id="tenant_ignore_missing")
+
+ assert result is None
+
+
+def test_get_model_by_model_id_ignore_delete_without_tenant_id(monkeypatch):
+ """Omitting tenant_id must look up the record purely by model_id."""
+ mock_model = SimpleNamespace(
+ model_id=107,
+ model_factory="openai",
+ model_type="chat",
+ tenant_id="some_tenant",
+ delete_flag="Y",
+ )
+ session = _patch_session(monkeypatch, scalars_first=mock_model)
+
+ result = model_mgmt_db.get_model_by_model_id_ignore_delete(107)
+
+ assert result is not None
+ assert result["model_id"] == 107
+ # Filter by model_id alone; the absence of tenant_id means we still call
+ # scalars() exactly once.
+ assert session.scalars.call_count == 1
diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py
index 50a0ef32b..df26f9d0f 100644
--- a/test/backend/services/test_agent_service.py
+++ b/test/backend/services/test_agent_service.py
@@ -491,7 +491,7 @@ async def test_get_agent_info_impl_success(mock_search_agent_info, mock_search_t
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [456, 789]
@@ -515,14 +515,16 @@ async def test_get_agent_info_impl_success(mock_search_agent_info, mock_search_t
result = await get_agent_info_impl(agent_id=123, tenant_id="test_tenant")
# Assert
+ expected_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
expected_result = {
"agent_id": 123,
"model_id": None,
"business_description": "Test agent",
- "tools": mock_tools,
+ "tools": expected_tools,
"sub_agent_id_list": mock_sub_agent_ids,
"skills": [],
"external_sub_agent_id_list": [],
+ "model_ids": [], # Added for get_valid_model_ids integration
"model_names": [],
"model_name": None,
"business_logic_model_name": None,
@@ -565,7 +567,7 @@ async def test_get_agent_info_impl_with_version_no(mock_search_agent_info, mock_
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [456, 789]
@@ -592,14 +594,16 @@ async def test_get_agent_info_impl_with_version_no(mock_search_agent_info, mock_
result = await get_agent_info_impl(agent_id=123, tenant_id="test_tenant", version_no=5)
# Assert
+ expected_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
expected_result = {
"agent_id": 123,
"model_id": None,
"business_description": "Test agent",
- "tools": mock_tools,
+ "tools": expected_tools,
"sub_agent_id_list": mock_sub_agent_ids,
"skills": [],
"external_sub_agent_id_list": [],
+ "model_ids": [], # Added for get_valid_model_ids integration
"model_names": [],
"model_name": None,
"business_logic_model_name": None,
@@ -1634,7 +1638,7 @@ async def test_get_agent_info_impl_sub_agent_error(mock_search_agent_info, mock_
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
# Mock query_sub_agents_id_list to raise an exception
@@ -1685,7 +1689,7 @@ async def test_get_agent_info_impl_with_model_id_success(mock_search_agent_info,
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [789]
@@ -1793,7 +1797,7 @@ async def test_get_agent_info_impl_with_model_id_no_display_name(mock_search_age
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [789]
@@ -1868,7 +1872,7 @@ async def test_get_agent_info_impl_with_model_id_none_model_info(mock_search_age
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [789]
@@ -1940,7 +1944,7 @@ async def test_get_agent_info_impl_with_business_logic_model(mock_search_agent_i
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [101, 102]
@@ -2038,7 +2042,7 @@ async def test_get_agent_info_impl_with_business_logic_model_none(mock_search_ag
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [101, 102]
@@ -2129,7 +2133,7 @@ async def test_get_agent_info_impl_with_business_logic_model_no_display_name(moc
}
mock_search_agent_info.return_value = mock_agent_info
- mock_tools = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_tools = [{"tool_id": 1, "name": "Tool 1", "unavailable_reasons": []}]
mock_search_tools.return_value = mock_tools
mock_sub_agent_ids = [101, 102]
@@ -2202,6 +2206,382 @@ def mock_get_model(model_id):
mock_get_model_by_model_id.assert_any_call(789)
+@patch("backend.services.agent_service.query_current_version_no")
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_marks_mcp_model_unavailable_when_deleted(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+ mock_query_current_version_no,
+):
+ """Tools whose selected_model_id has been soft-deleted should be marked mcp_model_unavailable."""
+ mock_search_agent_info.return_value = {
+ "agent_id": 123,
+ "model_id": None,
+ "business_description": "Test agent",
+ }
+
+ mock_tools = [
+ {
+ "tool_id": 1,
+ "name": "Tool 1",
+ "params": [{"name": "selected_model_id", "default": 99}],
+ },
+ {
+ "tool_id": 2,
+ "name": "Tool 2",
+ "params": [{"name": "selected_model_id", "default": 100}],
+ },
+ ]
+ mock_search_tools.return_value = mock_tools
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+
+ def fake_ignore_delete(model_id, tenant_id):
+ if model_id == 99:
+ return {"model_id": 99, "delete_flag": "Y"}
+ return {"model_id": model_id, "delete_flag": "N"}
+
+ mock_get_model_by_model_id_ignore_delete.side_effect = fake_ignore_delete
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=123, tenant_id="test_tenant")
+
+ assert result["tools"][0]["unavailable_reasons"] == ["mcp_model_unavailable"]
+ assert result["tools"][1]["unavailable_reasons"] == []
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_handles_tools_without_params(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """Tools without a params list should still return an empty unavailable_reasons and not crash."""
+ mock_search_agent_info.return_value = {
+ "agent_id": 123,
+ "model_id": None,
+ "business_description": "Test agent",
+ }
+
+ mock_tools = [
+ {"tool_id": 1, "name": "Tool 1"}, # no params key
+ {"tool_id": 2, "name": "Tool 2", "params": []}, # empty params list
+ {"tool_id": 3, "name": "Tool 3", "params": [{"name": "other", "default": "x"}]}, # no selected_model_id
+ ]
+ mock_search_tools.return_value = mock_tools
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=123, tenant_id="test_tenant")
+
+ for tool in result["tools"]:
+ assert tool["unavailable_reasons"] == []
+ mock_get_model_by_model_id_ignore_delete.assert_not_called()
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_skips_unset_selected_model_default(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """Tools with `selected_model_id` declared but no default should skip the DB lookup."""
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": None}
+ mock_search_tools.return_value = [
+ {"tool_id": 1, "name": "T1", "params": [{"name": "selected_model_id"}]},
+ {"tool_id": 2, "name": "T2", "params": [{"name": "selected_model_id", "default": None}]},
+ ]
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=1, tenant_id="test_tenant")
+
+ assert result["tools"][0]["unavailable_reasons"] == []
+ assert result["tools"][1]["unavailable_reasons"] == []
+ mock_get_model_by_model_id_ignore_delete.assert_not_called()
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_selected_model_not_found(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """When `get_model_by_model_id_ignore_delete` returns None we should not mark the tool unavailable."""
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": None}
+ mock_search_tools.return_value = [
+ {"tool_id": 1, "name": "T1", "params": [{"name": "selected_model_id", "default": 555}]},
+ ]
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=1, tenant_id="test_tenant")
+
+ assert result["tools"][0]["unavailable_reasons"] == []
+ mock_get_model_by_model_id_ignore_delete.assert_called_once_with(555, "test_tenant")
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_selected_model_not_deleted(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """A tool whose selected model is still active must not be marked unavailable."""
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": None}
+ mock_search_tools.return_value = [
+ {"tool_id": 1, "name": "T1", "params": [{"name": "selected_model_id", "default": 7}]},
+ ]
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = {"model_id": 7, "delete_flag": "N"}
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=1, tenant_id="test_tenant")
+
+ assert result["tools"][0]["unavailable_reasons"] == []
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_skips_non_list_params(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """Tool `params` values that aren't a list (e.g. dict, string) should be safely ignored."""
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": None}
+ mock_search_tools.return_value = [
+ {"tool_id": 1, "name": "T1", "params": {"selected_model_id": 5}},
+ {"tool_id": 2, "name": "T2", "params": "invalid"},
+ ]
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=1, tenant_id="test_tenant")
+
+ for tool in result["tools"]:
+ assert tool["unavailable_reasons"] == []
+ mock_get_model_by_model_id_ignore_delete.assert_not_called()
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_param_loop_skips_non_dict_entries(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """Within a list, non-dict entries must be skipped without crashing."""
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": None}
+ mock_search_tools.return_value = [
+ {
+ "tool_id": 1,
+ "name": "T1",
+ "params": [
+ None,
+ "string-not-a-dict",
+ 42,
+ True,
+ {"name": "selected_model_id", "default": 9},
+ ],
+ },
+ ]
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = {
+ "model_id": 9,
+ "delete_flag": "Y",
+ }
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=1, tenant_id="test_tenant")
+
+ assert result["tools"][0]["unavailable_reasons"] == ["mcp_model_unavailable"]
+ mock_get_model_by_model_id_ignore_delete.assert_called_once_with(9, "test_tenant")
+
+
+@patch("backend.services.agent_service.SkillService")
+@patch("backend.services.agent_service.query_external_sub_agents")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id_ignore_delete")
+@patch("backend.services.agent_service.query_sub_agents_id_list")
+@patch("backend.services.agent_service.search_tools_for_sub_agent")
+@patch("backend.services.agent_service.search_agent_info_by_agent_id")
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_breaks_after_selected_model_id(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id_ignore_delete,
+ mock_get_valid_model_ids,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """After locating `selected_model_id` other params in the same tool should be skipped."""
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": None}
+ mock_search_tools.return_value = [
+ {
+ "tool_id": 1,
+ "name": "T1",
+ "params": [
+ {"name": "selected_model_id", "default": 9},
+ {"name": "another_selected_model_id", "default": 10},
+ ],
+ },
+ ]
+ mock_query_sub_agents_id.return_value = []
+ mock_get_valid_model_ids.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+ mock_check_availability.return_value = (True, [])
+
+ result = await get_agent_info_impl(agent_id=1, tenant_id="test_tenant")
+
+ assert result["tools"][0]["unavailable_reasons"] == []
+ # Only one lookup per tool should be issued thanks to the `break`.
+ mock_get_model_by_model_id_ignore_delete.assert_called_once_with(9, "test_tenant")
+
+
@pytest.mark.asyncio
@patch("backend.services.agent_service.get_model_by_model_id")
@patch("backend.services.agent_service.check_agent_availability")
@@ -7835,6 +8215,7 @@ def test_check_single_model_availability_returns_empty_for_available_model():
# ============================================================================
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
@patch('backend.services.agent_service._collect_model_availability_reasons')
@patch('backend.services.agent_service.check_tool_is_available')
@patch('backend.services.agent_service.search_tools_for_sub_agent')
@@ -7843,7 +8224,8 @@ def test_check_agent_availability_all_available(
mock_search_agent_info,
mock_search_tools,
mock_check_tool,
- mock_collect_model_reasons
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
):
"""Test check_agent_availability when all tools and models are available."""
from backend.services.agent_service import check_agent_availability
@@ -7866,6 +8248,7 @@ def test_check_agent_availability_all_available(
mock_check_tool.assert_called_once_with([1, 2])
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
@patch('backend.services.agent_service._collect_model_availability_reasons')
@patch('backend.services.agent_service.check_tool_is_available')
@patch('backend.services.agent_service.search_tools_for_sub_agent')
@@ -7874,7 +8257,8 @@ def test_check_agent_availability_tool_unavailable(
mock_search_agent_info,
mock_search_tools,
mock_check_tool,
- mock_collect_model_reasons
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
):
"""Test check_agent_availability when some tools are unavailable."""
from backend.services.agent_service import check_agent_availability
@@ -7894,6 +8278,7 @@ def test_check_agent_availability_tool_unavailable(
assert reasons == ["tool_unavailable"]
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
@patch('backend.services.agent_service._collect_model_availability_reasons')
@patch('backend.services.agent_service.check_tool_is_available')
@patch('backend.services.agent_service.search_tools_for_sub_agent')
@@ -7902,7 +8287,8 @@ def test_check_agent_availability_model_unavailable(
mock_search_agent_info,
mock_search_tools,
mock_check_tool,
- mock_collect_model_reasons
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
):
"""Test check_agent_availability when model is unavailable."""
from backend.services.agent_service import check_agent_availability
@@ -7922,28 +8308,215 @@ def test_check_agent_availability_model_unavailable(
assert reasons == ["model_unavailable"]
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
@patch('backend.services.agent_service._collect_model_availability_reasons')
@patch('backend.services.agent_service.check_tool_is_available')
@patch('backend.services.agent_service.search_tools_for_sub_agent')
@patch('backend.services.agent_service.search_agent_info_by_agent_id')
-def test_check_agent_availability_both_unavailable(
+def test_check_agent_availability_mcp_model_deleted(
mock_search_agent_info,
mock_search_tools,
mock_check_tool,
- mock_collect_model_reasons
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
):
- """Test check_agent_availability when both tools and model are unavailable."""
+ """Test check_agent_availability when tool has selected_model_id pointing to a deleted model."""
from backend.services.agent_service import check_agent_availability
mock_agent_info = {"agent_id": 123, "model_id": 456}
mock_search_agent_info.return_value = mock_agent_info
- mock_search_tools.return_value = [{"tool_id": 1}]
- mock_check_tool.return_value = [False]
- mock_collect_model_reasons.return_value = ["model_unavailable"]
-
- is_available, reasons = check_agent_availability(
- agent_id=123,
- tenant_id="test_tenant"
+ mock_search_tools.return_value = [
+ {
+ "tool_id": 1,
+ "params": [{"name": "selected_model_id", "default": 99}],
+ },
+ {
+ "tool_id": 2,
+ "params": [{"name": "selected_model_id", "default": 100}],
+ },
+ ]
+ mock_check_tool.return_value = [True, True]
+ mock_collect_model_reasons.return_value = []
+
+ def fake_ignore_delete(model_id, tenant_id):
+ if model_id == 99:
+ return {"model_id": 99, "delete_flag": "Y"}
+ return {"model_id": model_id, "delete_flag": "N"}
+
+ mock_get_model_by_model_id_ignore_delete.side_effect = fake_ignore_delete
+
+ is_available, reasons = check_agent_availability(
+ agent_id=123,
+ tenant_id="test_tenant"
+ )
+
+ assert is_available is False
+ # Agent-level unavailability re-uses the same TOOL_UNAVAILABLE reason as
+ # the per-tool check, but it is only emitted once even when multiple tools
+ # point to deleted models.
+ assert "tool_unavailable" in reasons
+ assert reasons.count("tool_unavailable") == 1
+ # confirm the deleted model was the source of the reason
+ expected_lookups = {99, 100}
+ looked_up = {call.args[0]
+ for call in mock_get_model_by_model_id_ignore_delete.call_args_list}
+ assert looked_up == expected_lookups
+
+
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
+@patch('backend.services.agent_service._collect_model_availability_reasons')
+@patch('backend.services.agent_service.check_tool_is_available')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+def test_check_agent_availability_mcp_model_selected_default_none(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_check_tool,
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
+):
+ """`selected_model_id` set without a default value should be skipped without lookups."""
+ from backend.services.agent_service import check_agent_availability
+
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": 1}
+ mock_search_tools.return_value = [
+ {"tool_id": 1, "params": [{"name": "selected_model_id"}]}, # default is None
+ ]
+ mock_check_tool.return_value = [True]
+ mock_collect_model_reasons.return_value = []
+
+ is_available, reasons = check_agent_availability(agent_id=1, tenant_id="t")
+
+ assert is_available is True
+ assert reasons == []
+ mock_get_model_by_model_id_ignore_delete.assert_not_called()
+
+
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
+@patch('backend.services.agent_service._collect_model_availability_reasons')
+@patch('backend.services.agent_service.check_tool_is_available')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+def test_check_agent_availability_mcp_model_record_missing(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_check_tool,
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
+):
+ """If the DB lookup returns None we should not raise and not add a reason."""
+ from backend.services.agent_service import check_agent_availability
+
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": 1}
+ mock_search_tools.return_value = [
+ {"tool_id": 1, "params": [{"name": "selected_model_id", "default": 999}]},
+ ]
+ mock_check_tool.return_value = [True]
+ mock_collect_model_reasons.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ is_available, reasons = check_agent_availability(agent_id=1, tenant_id="t")
+
+ assert is_available is True
+ assert reasons == []
+
+
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
+@patch('backend.services.agent_service._collect_model_availability_reasons')
+@patch('backend.services.agent_service.check_tool_is_available')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+def test_check_agent_availability_mcp_model_params_non_dict_entries(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_check_tool,
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
+):
+ """Non-dict entries inside `params` (and missing `params`) must not crash the loop."""
+ from backend.services.agent_service import check_agent_availability
+
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": 1}
+ mock_search_tools.return_value = [
+ {"tool_id": 1}, # no params
+ {"tool_id": 2, "params": None}, # explicit None
+ {"tool_id": 3, "params": "not-a-list"}, # wrong type, skip
+ {"tool_id": 4, "params": [None, "string", 42, True]}, # non-dict entries
+ {"tool_id": 5, "params": [{"name": "unrelated", "default": 7}]}, # wrong param name
+ ]
+ mock_check_tool.return_value = [True] * 5
+ mock_collect_model_reasons.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ is_available, reasons = check_agent_availability(agent_id=1, tenant_id="t")
+
+ assert is_available is True
+ assert reasons == []
+ # No selected_model_id found in any tool's params, so the lookup is never invoked.
+ mock_get_model_by_model_id_ignore_delete.assert_not_called()
+
+
+@patch('backend.services.agent_service.get_model_by_model_id_ignore_delete')
+@patch('backend.services.agent_service._collect_model_availability_reasons')
+@patch('backend.services.agent_service.check_tool_is_available')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+def test_check_agent_availability_mcp_model_loop_breaks_after_first_match(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_check_tool,
+ mock_collect_model_reasons,
+ mock_get_model_by_model_id_ignore_delete,
+):
+ """Once `selected_model_id` is located, subsequent params in the same tool are skipped."""
+ from backend.services.agent_service import check_agent_availability
+
+ mock_search_agent_info.return_value = {"agent_id": 1, "model_id": 1}
+ mock_search_tools.return_value = [
+ {
+ "tool_id": 1,
+ "params": [
+ {"name": "other_param", "default": 1},
+ {"name": "selected_model_id", "default": 99},
+ {"name": "trailing", "default": 2},
+ ],
+ },
+ ]
+ mock_check_tool.return_value = [True]
+ mock_collect_model_reasons.return_value = []
+ mock_get_model_by_model_id_ignore_delete.return_value = None
+
+ is_available, reasons = check_agent_availability(agent_id=1, tenant_id="t")
+
+ assert is_available is True
+ assert reasons == []
+ # Only one lookup should be performed for the tool, regardless of how many
+ # other params follow `selected_model_id` in the schema list.
+ assert mock_get_model_by_model_id_ignore_delete.call_count == 1
+
+
+@patch('backend.services.agent_service._collect_model_availability_reasons')
+@patch('backend.services.agent_service.check_tool_is_available')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+def test_check_agent_availability_both_unavailable(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_check_tool,
+ mock_collect_model_reasons
+):
+ """Test check_agent_availability when both tools and model are unavailable."""
+ from backend.services.agent_service import check_agent_availability
+
+ mock_agent_info = {"agent_id": 123, "model_id": 456}
+ mock_search_agent_info.return_value = mock_agent_info
+ mock_search_tools.return_value = [{"tool_id": 1}]
+ mock_check_tool.return_value = [False]
+ mock_collect_model_reasons.return_value = ["model_unavailable"]
+
+ is_available, reasons = check_agent_availability(
+ agent_id=123,
+ tenant_id="test_tenant"
)
assert is_available is False
@@ -9536,6 +10109,20 @@ def _stub_requested_output_tokens_validator():
yield
+@pytest.fixture(autouse=True)
+def _stub_get_valid_model_ids():
+ """Auto-mock get_valid_model_ids to pass through when not explicitly mocked by a test.
+
+ This fixture ensures that existing tests that don't mock get_valid_model_ids still work.
+ Tests that need to verify get_valid_model_ids behavior can mock it explicitly.
+ """
+ with patch(
+ "backend.services.agent_service.get_valid_model_ids",
+ side_effect=lambda model_ids, tenant_id: model_ids,
+ ):
+ yield
+
+
# Tests for update_agent_info_impl skill handling exception
@patch("backend.services.agent_service.skill_db.create_or_update_skill_by_skill_info")
@patch("backend.services.agent_service.skill_db.query_skill_instances_by_agent_id")
@@ -13842,3 +14429,293 @@ async def mock_title_gen(*args, **kwargs):
# Should complete successfully
assert response.status_code == 200
+
+
+# ============================================================================
+# Tests for get_valid_model_ids integration in list_all_agent_info_impl
+# ============================================================================
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_filters_deleted_models(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+ mock_get_valid_model_ids,
+):
+ """Test that list_all_agent_info_impl filters out deleted models from model_ids.
+
+ This test verifies that:
+ 1. get_valid_model_ids is called to filter deleted models
+ 2. The filtered model_ids are used for availability check and model name resolution
+ 3. The returned model_ids only contain valid (non-deleted) models
+ """
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Test agent with models",
+ "enabled": True,
+ "model_ids": [1, 2, 3], # Original model_ids including deleted ones
+ "group_ids": "",
+ "created_by": "user1",
+ "create_time": 1,
+ }
+ ]
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "ADMIN"}
+ mock_query_groups.return_value = []
+ mock_convert_list.return_value = []
+
+ # Mock get_valid_model_ids to filter out model_id=2 (deleted)
+ mock_get_valid_model_ids.return_value = [1, 3] # Only models 1 and 3 are valid
+
+ # Mock model info for valid models (get_model_by_model_id takes 2 args: model_id and tenant_id)
+ def get_model_side_effect(model_id, tenant_id=None):
+ if model_id == 1:
+ return {"display_name": "Model 1", "model_id": 1}
+ elif model_id == 3:
+ return {"display_name": "Model 3", "model_id": 3}
+ return None
+ mock_get_model.side_effect = get_model_side_effect
+
+ mock_check_availability.return_value = (True, [])
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="admin_user")
+
+ # Verify get_valid_model_ids was called with original model_ids and tenant_id
+ mock_get_valid_model_ids.assert_called_once_with([1, 2, 3], "test_tenant")
+
+ # Verify result contains only valid model_ids
+ assert len(result) == 1
+ assert result[0]["model_ids"] == [1, 3]
+ assert result[0]["model_names"] == ["Model 1", "Model 3"]
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_all_models_deleted(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+ mock_get_valid_model_ids,
+):
+ """Test that list_all_agent_info_impl handles when all models are deleted.
+
+ This test verifies that:
+ 1. get_valid_model_ids returns empty list when all models are deleted
+ 2. model_names is empty
+ 3. Availability check is still performed with empty model_ids
+ """
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Test agent",
+ "enabled": True,
+ "model_ids": [1, 2, 3],
+ "group_ids": "",
+ "created_by": "user1",
+ "create_time": 1,
+ }
+ ]
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "ADMIN"}
+ mock_query_groups.return_value = []
+ mock_convert_list.return_value = []
+
+ # All models were deleted
+ mock_get_valid_model_ids.return_value = []
+
+ mock_check_availability.return_value = (True, [])
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="admin_user")
+
+ # Verify result has empty model_ids and model_names
+ assert len(result) == 1
+ assert result[0]["model_ids"] == []
+ assert result[0]["model_names"] == []
+ assert result[0]["model_name"] is None
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_valid_model_ids")
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_empty_model_ids(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+ mock_get_valid_model_ids,
+):
+ """Test that list_all_agent_info_impl handles empty model_ids."""
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Test agent",
+ "enabled": True,
+ "model_ids": [], # Empty model_ids
+ "group_ids": "",
+ "created_by": "user1",
+ "create_time": 1,
+ }
+ ]
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "ADMIN"}
+ mock_query_groups.return_value = []
+ mock_convert_list.return_value = []
+
+ # get_valid_model_ids should be called with empty list
+ mock_get_valid_model_ids.return_value = []
+
+ mock_check_availability.return_value = (True, [])
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="admin_user")
+
+ mock_get_valid_model_ids.assert_called_once_with([], "test_tenant")
+ assert result[0]["model_ids"] == []
+ assert result[0]["model_names"] == []
+
+
+# ============================================================================
+# Tests for get_valid_model_ids integration in get_agent_info_impl
+# ============================================================================
+
+
+@patch('backend.services.agent_service.SkillService')
+@patch('backend.services.agent_service.query_external_sub_agents')
+@patch('backend.services.agent_service.check_agent_availability')
+@patch('backend.services.agent_service.get_model_by_model_id')
+@patch('backend.services.agent_service.query_sub_agents_id_list')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_filters_deleted_models(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """Test that get_agent_info_impl filters out deleted models from model_ids.
+
+ This test verifies that:
+ 1. get_valid_model_ids is called to filter deleted models
+ 2. The filtered model_ids are used for availability check and model name resolution
+ 3. The returned model_ids only contain valid (non-deleted) models
+ """
+ mock_agent_info = {
+ "agent_id": 123,
+ "model_ids": [1, 2, 3], # Original model_ids including deleted ones
+ "business_description": "Test agent"
+ }
+ mock_search_agent_info.return_value = mock_agent_info
+ mock_search_tools.return_value = [{"tool_id": 1, "name": "Tool 1"}]
+ mock_query_sub_agents_id.return_value = [456]
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+
+ # Mock get_model_by_model_id for valid models
+ def get_model_side_effect(model_id, tenant_id=None):
+ if model_id == 1:
+ return {"display_name": "Model 1", "model_id": 1}
+ elif model_id == 3:
+ return {"display_name": "Model 3", "model_id": 3}
+ return None
+ mock_get_model_by_model_id.side_effect = get_model_side_effect
+
+ mock_check_availability.return_value = (True, [])
+
+ # Mock get_valid_model_ids to filter out model_id=2 (deleted)
+ with patch("backend.services.agent_service.get_valid_model_ids") as mock_get_valid_model_ids:
+ mock_get_valid_model_ids.return_value = [1, 3]
+
+ result = await get_agent_info_impl(agent_id=123, tenant_id="test_tenant")
+
+ # Verify get_valid_model_ids was called
+ mock_get_valid_model_ids.assert_called_once_with([1, 2, 3], "test_tenant")
+
+ # Verify result contains only valid model_ids
+ assert result["model_ids"] == [1, 3]
+ assert result["model_names"] == ["Model 1", "Model 3"]
+ assert result["model_name"] == "Model 1" # First model's display_name
+
+
+@patch('backend.services.agent_service.SkillService')
+@patch('backend.services.agent_service.query_external_sub_agents')
+@patch('backend.services.agent_service.check_agent_availability')
+@patch('backend.services.agent_service.get_model_by_model_id')
+@patch('backend.services.agent_service.query_sub_agents_id_list')
+@patch('backend.services.agent_service.search_tools_for_sub_agent')
+@patch('backend.services.agent_service.search_agent_info_by_agent_id')
+@pytest.mark.asyncio
+async def test_get_agent_info_impl_all_models_deleted(
+ mock_search_agent_info,
+ mock_search_tools,
+ mock_query_sub_agents_id,
+ mock_get_model_by_model_id,
+ mock_check_availability,
+ mock_query_external_sub_agents,
+ mock_skill_service,
+):
+ """Test that get_agent_info_impl handles when all models are deleted."""
+ mock_agent_info = {
+ "agent_id": 123,
+ "model_ids": [1, 2, 3],
+ "business_description": "Test agent"
+ }
+ mock_search_agent_info.return_value = mock_agent_info
+ mock_search_tools.return_value = []
+ mock_query_sub_agents_id.return_value = []
+
+ mock_skill_service_instance = MagicMock()
+ mock_skill_service_instance.list_skill_instances.return_value = []
+ mock_skill_service.return_value = mock_skill_service_instance
+ mock_query_external_sub_agents.return_value = []
+
+ mock_get_model_by_model_id.return_value = None
+ mock_check_availability.return_value = (True, [])
+
+ with patch("backend.services.agent_service.get_valid_model_ids") as mock_get_valid_model_ids:
+ mock_get_valid_model_ids.return_value = [] # All models deleted
+
+ result = await get_agent_info_impl(agent_id=123, tenant_id="test_tenant")
+
+ assert result["model_ids"] == []
+ assert result["model_names"] == []
+ assert result["model_name"] is None
diff --git a/test/backend/services/test_agent_version_service.py b/test/backend/services/test_agent_version_service.py
index cd0cd3241..548ca2946 100644
--- a/test/backend/services/test_agent_version_service.py
+++ b/test/backend/services/test_agent_version_service.py
@@ -120,6 +120,10 @@ class ValidationError(Exception):
# Mock database.model_management_db
model_management_db_mock = MagicMock()
+# Default pass-through behavior for get_valid_model_ids
+model_management_db_mock.get_valid_model_ids = MagicMock(
+ side_effect=lambda model_ids, tenant_id: model_ids
+)
sys.modules['database.model_management_db'] = model_management_db_mock
sys.modules['backend.database.model_management_db'] = model_management_db_mock
@@ -3190,3 +3194,181 @@ def test_list_published_agents_impl_model_ids_empty(monkeypatch):
assert result[0]["model_names"] == []
assert result[0]["model_name"] is None
assert result[0]["is_available"] is False
+
+
+# ============================================================================
+# Tests for get_valid_model_ids integration in list_published_agents_impl
+# ============================================================================
+
+
+def test_list_published_agents_impl_filters_deleted_models(monkeypatch):
+ """Test that list_published_agents_impl filters out deleted models from model_ids.
+
+ This test verifies that:
+ 1. get_valid_model_ids is called to filter deleted models
+ 2. The filtered model_ids are used for availability check and model name resolution
+ 3. The returned model_ids only contain valid (non-deleted) models
+ """
+ agent_db_mock.query_all_agent_info_by_tenant_id = MagicMock(
+ return_value=[
+ {
+ "agent_id": 1,
+ "enabled": True,
+ "current_version_no": 1,
+ "group_ids": "1,2",
+ "created_by": "user1",
+ "name": "Test Agent",
+ "display_name": "Test Agent",
+ "description": "Test",
+ "author": "Author",
+ "is_new": False,
+ }
+ ]
+ )
+
+ agent_service_mock.get_user_tenant_by_user_id = MagicMock(
+ return_value={"user_role": "ADMIN"}
+ )
+ agent_service_mock.query_group_ids_by_user = MagicMock(return_value=[1, 2])
+
+ agent_version_db_mock.query_agent_snapshot = MagicMock(
+ return_value=(
+ {
+ "agent_id": 1,
+ "name": "Test Agent",
+ "model_ids": [1, 2, 3], # Original model_ids including deleted ones
+ "description": "Test",
+ },
+ [{"tool_id": 1, "enabled": True}],
+ [],
+ )
+ )
+
+ # Mock get_valid_model_ids to filter out model_id=2 (deleted)
+ agent_version_service_module.get_valid_model_ids = MagicMock(return_value=[1, 3])
+
+ agent_service_mock.check_agent_availability = MagicMock(
+ return_value=(True, [])
+ )
+ agent_service_mock._apply_duplicate_name_availability_rules = MagicMock()
+
+ # Mock model info for valid models
+ def get_model_side_effect(model_id, tenant_id=None):
+ if model_id == 1:
+ return {"display_name": "Model 1", "model_id": 1}
+ elif model_id == 3:
+ return {"display_name": "Model 3", "model_id": 3}
+ return None
+ agent_service_mock.get_model_by_model_id = MagicMock(side_effect=get_model_side_effect)
+
+ result = asyncio.run(list_published_agents_impl(tenant_id="tenant1", user_id="user1"))
+
+ # Verify get_valid_model_ids was called
+ agent_version_service_module.get_valid_model_ids.assert_called_once_with([1, 2, 3], "tenant1")
+
+ # Verify result contains only valid model_ids
+ assert len(result) == 1
+ assert result[0]["model_ids"] == [1, 3]
+ assert result[0]["model_names"] == ["Model 1", "Model 3"]
+ assert result[0]["model_name"] == "Model 1"
+
+
+def test_list_published_agents_impl_all_models_deleted(monkeypatch):
+ """Test that list_published_agents_impl handles when all models are deleted."""
+ agent_db_mock.query_all_agent_info_by_tenant_id = MagicMock(
+ return_value=[
+ {
+ "agent_id": 1,
+ "enabled": True,
+ "current_version_no": 1,
+ "group_ids": "1,2",
+ "created_by": "user1",
+ "name": "Test Agent",
+ "display_name": "Test Agent",
+ "description": "Test",
+ }
+ ]
+ )
+
+ agent_service_mock.get_user_tenant_by_user_id = MagicMock(
+ return_value={"user_role": "ADMIN"}
+ )
+
+ agent_version_db_mock.query_agent_snapshot = MagicMock(
+ return_value=(
+ {
+ "agent_id": 1,
+ "name": "Test Agent",
+ "model_ids": [1, 2, 3],
+ "description": "Test",
+ },
+ [],
+ [],
+ )
+ )
+
+ # All models were deleted
+ agent_version_service_module.get_valid_model_ids = MagicMock(return_value=[])
+
+ agent_service_mock.check_agent_availability = MagicMock(
+ return_value=(True, [])
+ )
+ agent_service_mock._apply_duplicate_name_availability_rules = MagicMock()
+ agent_service_mock.get_model_by_model_id = MagicMock(return_value=None)
+
+ result = asyncio.run(list_published_agents_impl(tenant_id="tenant1", user_id="user1"))
+
+ assert len(result) == 1
+ assert result[0]["model_ids"] == []
+ assert result[0]["model_names"] == []
+ assert result[0]["model_name"] is None
+
+
+def test_list_published_agents_impl_empty_model_ids(monkeypatch):
+ """Test that list_published_agents_impl handles empty model_ids."""
+ agent_db_mock.query_all_agent_info_by_tenant_id = MagicMock(
+ return_value=[
+ {
+ "agent_id": 1,
+ "enabled": True,
+ "current_version_no": 1,
+ "group_ids": "1,2",
+ "created_by": "user1",
+ "name": "Test Agent",
+ "display_name": "Test Agent",
+ "description": "Test",
+ }
+ ]
+ )
+
+ agent_service_mock.get_user_tenant_by_user_id = MagicMock(
+ return_value={"user_role": "ADMIN"}
+ )
+
+ agent_version_db_mock.query_agent_snapshot = MagicMock(
+ return_value=(
+ {
+ "agent_id": 1,
+ "name": "Test Agent",
+ "model_ids": [], # Empty model_ids
+ "description": "Test",
+ },
+ [],
+ [],
+ )
+ )
+
+ # get_valid_model_ids should be called with empty list
+ agent_version_service_module.get_valid_model_ids = MagicMock(return_value=[])
+
+ agent_service_mock.check_agent_availability = MagicMock(
+ return_value=(True, [])
+ )
+ agent_service_mock._apply_duplicate_name_availability_rules = MagicMock()
+ agent_service_mock.get_model_by_model_id = MagicMock(return_value=None)
+
+ result = asyncio.run(list_published_agents_impl(tenant_id="tenant1", user_id="user1"))
+
+ agent_version_service_module.get_valid_model_ids.assert_called_once_with([], "tenant1")
+ assert result[0]["model_ids"] == []
+ assert result[0]["model_names"] == []
From 2a9832cb0f1aa74b5bff5b12a3119397988adb19 Mon Sep 17 00:00:00 2001
From: Jason Wang <56037774+JasonW404@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:07:14 +0800
Subject: [PATCH 16/26] Disable context component budget pruning (#3394)
---
backend/agents/create_agent_info.py | 1 +
sdk/nexent/core/agents/agent_context/manager.py | 2 +-
sdk/nexent/core/agents/summary_config.py | 4 ++--
.../test_agent_context/unit/test_component_management.py | 8 ++++----
test/sdk/core/agents/test_context_component.py | 2 +-
.../agents/test_nexent_agent_component_integration.py | 5 +++--
6 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py
index 2158a5dec..5ed4a9b1c 100644
--- a/backend/agents/create_agent_info.py
+++ b/backend/agents/create_agent_info.py
@@ -958,6 +958,7 @@ async def create_agent_config(
token_threshold=context_token_threshold,
soft_input_budget_tokens=soft_input_budget_tokens,
hard_input_budget_tokens=hard_input_budget_tokens,
+ strategy="full",
)
agent_config = AgentConfig(
name="undefined" if agent_info["name"] is None else agent_info["name"],
diff --git a/sdk/nexent/core/agents/agent_context/manager.py b/sdk/nexent/core/agents/agent_context/manager.py
index 483b3b6d3..3eeb81b87 100644
--- a/sdk/nexent/core/agents/agent_context/manager.py
+++ b/sdk/nexent/core/agents/agent_context/manager.py
@@ -724,7 +724,7 @@ def _get_strategy(self):
"buffered": BufferedStrategy,
"priority": PriorityWeightedStrategy,
}
- strategy_class = strategy_map.get(self.config.strategy, TokenBudgetStrategy)
+ strategy_class = strategy_map.get(self.config.strategy, FullStrategy)
if self.config.strategy == "buffered":
return strategy_class(buffer_size=self.config.buffer_size_per_component)
diff --git a/sdk/nexent/core/agents/summary_config.py b/sdk/nexent/core/agents/summary_config.py
index fcca60eb5..8583e3308 100644
--- a/sdk/nexent/core/agents/summary_config.py
+++ b/sdk/nexent/core/agents/summary_config.py
@@ -69,11 +69,11 @@ class ContextManagerConfig:
max_observation_length: int = 0
# === NEW: Strategy Selection ===
- strategy: StrategyType = "token_budget"
+ strategy: StrategyType = "full"
"""Context component selection strategy.
Options:
- - 'full': Keep all components (for unlimited context models)
+ - 'full': Keep all components without component budget pruning
- 'token_budget': Select components within token budget by priority
- 'buffered': Keep last N components per type
- 'priority': Weight by importance + relevance scores
diff --git a/test/sdk/core/agents/test_agent_context/unit/test_component_management.py b/test/sdk/core/agents/test_agent_context/unit/test_component_management.py
index 28c7cbb7c..feb2bbaa0 100644
--- a/test/sdk/core/agents/test_agent_context/unit/test_component_management.py
+++ b/test/sdk/core/agents/test_agent_context/unit/test_component_management.py
@@ -163,10 +163,10 @@ def test_preserves_component_order(self):
class TestGetStrategy:
"""Tests for _get_strategy() method."""
- def test_default_returns_token_budget_strategy(self):
+ def test_default_returns_full_strategy(self):
cm = ContextManager()
strategy = cm._get_strategy()
- assert strategy.get_strategy_name() == "token_budget"
+ assert strategy.get_strategy_name() == "full"
def test_full_strategy(self):
config = ContextManagerConfig(strategy="full")
@@ -187,11 +187,11 @@ def test_priority_strategy(self):
strategy = cm._get_strategy()
assert strategy.get_strategy_name() == "priority"
- def test_unknown_strategy_defaults_to_token_budget(self):
+ def test_unknown_strategy_defaults_to_full(self):
config = ContextManagerConfig(strategy="unknown")
cm = ContextManager(config)
strategy = cm._get_strategy()
- assert strategy.get_strategy_name() == "token_budget"
+ assert strategy.get_strategy_name() == "full"
class TestBuildSystemPrompt:
diff --git a/test/sdk/core/agents/test_context_component.py b/test/sdk/core/agents/test_context_component.py
index 472fbf42c..b52527dcb 100644
--- a/test/sdk/core/agents/test_context_component.py
+++ b/test/sdk/core/agents/test_context_component.py
@@ -742,7 +742,7 @@ class TestExtendedContextManagerConfig:
def test_default_strategy(self):
config = summary_config_module.ContextManagerConfig()
- assert config.strategy == "token_budget"
+ assert config.strategy == "full"
def test_all_injection_flags_default_true(self):
config = summary_config_module.ContextManagerConfig()
diff --git a/test/sdk/core/agents/test_nexent_agent_component_integration.py b/test/sdk/core/agents/test_nexent_agent_component_integration.py
index fe6057608..a8561b81c 100644
--- a/test/sdk/core/agents/test_nexent_agent_component_integration.py
+++ b/test/sdk/core/agents/test_nexent_agent_component_integration.py
@@ -13,6 +13,7 @@
STRATEGY_TOKEN_BUDGET = "token_budget"
+STRATEGY_FULL = "full"
class TestNexentAgentComponentRegistration:
@@ -216,7 +217,7 @@ def test_agent_config_without_components_still_works(self):
def test_context_manager_config_without_strategy_defaults(self):
config = ContextManagerConfig(token_threshold=2000)
- assert config.strategy == STRATEGY_TOKEN_BUDGET
+ assert config.strategy == STRATEGY_FULL
assert "system_prompt" in config.component_budgets
@@ -277,4 +278,4 @@ def test_replace_components_with_empty_list(self):
)
conversation_cm.replace_components([])
- assert conversation_cm.get_registered_components() == []
\ No newline at end of file
+ assert conversation_cm.get_registered_components() == []
From 5f13d6eba250e4b01e0764cad7a52ef513f70d3b Mon Sep 17 00:00:00 2001
From: gjc199 <97944442+gjc199@users.noreply.github.com>
Date: Fri, 10 Jul 2026 15:10:07 +0800
Subject: [PATCH 17/26] =?UTF-8?q?=F0=9F=90=9BBugfix:=20Persist=20conversat?=
=?UTF-8?q?ion=20agent=20and=20verify=20jwt=20expiry=20(#3392)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: persist conversation agent and verify jwt expiry
* test: cover conversation agent and jwt expiry changes
* fix: refresh published agents after deletion
* 11
---
backend/database/conversation_db.py | 42 ++++++-
backend/database/db_models.py | 1 +
backend/services/agent_service.py | 15 +++
.../conversation_management_service.py | 21 +++-
backend/utils/auth_utils.py | 29 ++++-
.../v2.3.0_0709_add_conversation_agent_id.sql | 6 +
.../agents/components/AgentSelectorHeader.tsx | 3 +-
.../components/agentManage/AgentList.tsx | 3 +-
.../[locale]/chat/internal/chatInterface.tsx | 115 +++++++++++++++---
frontend/hooks/agent/usePublishedAgentList.ts | 1 +
.../hooks/chat/useConversationManagement.ts | 26 +++-
frontend/public/locales/en/common.json | 4 +-
frontend/types/chat.ts | 2 +
frontend/types/conversation.ts | 2 +
test/backend/database/test_conversation_db.py | 65 +++++++++-
.../test_conversation_management_service.py | 63 +++++++++-
test/backend/utils/test_auth_utils.py | 69 +++++++++++
17 files changed, 430 insertions(+), 37 deletions(-)
create mode 100644 deploy/sql/migrations/v2.3.0_0709_add_conversation_agent_id.sql
diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py
index a82c00d24..9efd49881 100644
--- a/backend/database/conversation_db.py
+++ b/backend/database/conversation_db.py
@@ -46,19 +46,22 @@ class ImageRecord(TypedDict):
class ConversationHistory(TypedDict):
conversation_id: int
+ agent_id: Optional[int]
create_time: int
message_records: List[MessageRecord]
search_records: List[SearchRecord]
image_records: List[ImageRecord]
-def create_conversation(conversation_title: str, user_id: Optional[str] = None) -> Dict[str, Any]:
+def create_conversation(conversation_title: str, user_id: Optional[str] = None,
+ agent_id: Optional[int] = None) -> Dict[str, Any]:
"""
Create a new conversation record
Args:
conversation_title: Conversation title
user_id: Reserved parameter for created_by and updated_by fields
+ agent_id: Agent used by the latest run in this conversation
Returns:
Dict[str, Any]: Dictionary containing complete information of the newly created conversation
@@ -66,12 +69,15 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None)
with get_db_session() as session:
# Prepare data dictionary
data = {"conversation_title": conversation_title, "delete_flag": 'N'}
+ if agent_id is not None:
+ data["agent_id"] = agent_id
if user_id:
data = add_creation_tracking(data, user_id)
stmt = insert(ConversationRecord).values(**data).returning(
ConversationRecord.conversation_id,
ConversationRecord.conversation_title,
+ ConversationRecord.agent_id,
(func.extract('epoch', ConversationRecord.create_time)
* 1000).label('create_time'),
(func.extract('epoch', ConversationRecord.update_time)
@@ -84,6 +90,7 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None)
result_dict = {
"conversation_id": record.conversation_id,
"conversation_title": record.conversation_title,
+ "agent_id": record.agent_id,
"create_time": int(record.create_time),
"update_time": int(record.update_time)
}
@@ -433,6 +440,7 @@ def get_conversation_list(user_id: Optional[str] = None) -> List[Dict[str, Any]]
stmt = select(
ConversationRecord.conversation_id,
ConversationRecord.conversation_title,
+ ConversationRecord.agent_id,
(func.extract('epoch', ConversationRecord.create_time)
* 1000).label('create_time'),
(func.extract('epoch', ConversationRecord.update_time)
@@ -461,6 +469,36 @@ def get_conversation_list(user_id: Optional[str] = None) -> List[Dict[str, Any]]
return result
+def update_conversation_agent_id(conversation_id: int, agent_id: int, user_id: Optional[str] = None) -> bool:
+ """
+ Update the agent associated with a conversation.
+
+ Args:
+ conversation_id: Conversation ID (integer)
+ agent_id: Latest agent ID used by this conversation
+ user_id: Reserved parameter for updated_by field
+
+ Returns:
+ bool: Whether the operation was successful
+ """
+ with get_db_session() as session:
+ conversation_id = int(conversation_id)
+ update_data = {
+ "agent_id": int(agent_id),
+ "update_time": func.current_timestamp()
+ }
+ if user_id:
+ update_data = add_update_tracking(update_data, user_id)
+
+ stmt = update(ConversationRecord).where(
+ ConversationRecord.conversation_id == conversation_id,
+ ConversationRecord.delete_flag == 'N'
+ ).values(update_data)
+
+ result = session.execute(stmt)
+ return result.rowcount > 0
+
+
def rename_conversation(conversation_id: int, new_title: str, user_id: Optional[str] = None) -> bool:
"""
Rename a conversation
@@ -678,6 +716,7 @@ def get_conversation_history(conversation_id: int, user_id: Optional[str] = None
# First check if conversation exists
check_stmt = select(
ConversationRecord.conversation_id,
+ ConversationRecord.agent_id,
(func.extract('epoch', ConversationRecord.create_time)
* 1000).label('create_time')
).where(
@@ -769,6 +808,7 @@ def get_conversation_history(conversation_id: int, user_id: Optional[str] = None
return {
'conversation_id': conversation['conversation_id'],
+ 'agent_id': conversation.get('agent_id'),
'create_time': int(conversation['create_time']),
'message_records': message_list,
'search_records': [as_dict(record) for record in search_records],
diff --git a/backend/database/db_models.py b/backend/database/db_models.py
index 4f4b2f553..3c078d9b4 100644
--- a/backend/database/db_models.py
+++ b/backend/database/db_models.py
@@ -43,6 +43,7 @@ class ConversationRecord(TableBase):
conversation_id = Column(Integer, Sequence(
"conversation_record_t_conversation_id_seq", schema=SCHEMA), primary_key=True, nullable=False)
conversation_title = Column(String(100), doc="Conversation title")
+ agent_id = Column(Integer, doc="Agent ID used by the latest run in this conversation")
class ConversationMessage(TableBase):
diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py
index c027cabaa..b6b7151b8 100644
--- a/backend/services/agent_service.py
+++ b/backend/services/agent_service.py
@@ -99,6 +99,7 @@
save_source_image,
save_source_search,
save_skill_files_to_conversation,
+ update_conversation_agent_id_service,
update_message_content,
update_message_status,
update_unit_content,
@@ -2989,6 +2990,7 @@ async def run_agent_stream(
conversation_data = create_new_conversation(
title=default_title,
user_id=resolved_user_id,
+ agent_id=agent_request.agent_id,
)
agent_request.conversation_id = conversation_data["conversation_id"]
is_new_conversation = True
@@ -2998,6 +3000,19 @@ async def run_agent_stream(
resolved_user_id,
)
+ if (
+ not agent_request.is_debug
+ and not resume
+ and not is_new_conversation
+ and agent_request.conversation_id is not None
+ and agent_request.agent_id is not None
+ ):
+ update_conversation_agent_id_service(
+ conversation_id=agent_request.conversation_id,
+ agent_id=agent_request.agent_id,
+ user_id=resolved_user_id,
+ )
+
# Resume mode: check for existing streaming message
if resume:
resume_info = _detect_resume_position(
diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py
index 64482416c..772d8d31a 100644
--- a/backend/services/conversation_management_service.py
+++ b/backend/services/conversation_management_service.py
@@ -28,6 +28,7 @@
get_source_searches_by_conversation,
get_source_searches_by_message,
rename_conversation,
+ update_conversation_agent_id,
update_conversation_message_content,
update_conversation_message_status,
update_message_minio_files,
@@ -290,19 +291,20 @@ def update_conversation_title(conversation_id: int, title: str, user_id: str = N
return success
-def create_new_conversation(title: str, user_id: str) -> Dict[str, Any]:
+def create_new_conversation(title: str, user_id: str, agent_id: Optional[int] = None) -> Dict[str, Any]:
"""
Create a new conversation
Args:
title: Conversation title
user_id: User ID
+ agent_id: Agent used by the latest run in this conversation
Returns:
Dict containing conversation data
"""
try:
- conversation_data = create_conversation(title, user_id)
+ conversation_data = create_conversation(title, user_id, agent_id=agent_id)
return conversation_data
except Exception as e:
logging.error(f"Failed to create conversation: {str(e)}")
@@ -324,6 +326,20 @@ def get_conversation_list_service(user_id: str) -> List[Dict[str, Any]]:
raise Exception(str(e))
+def update_conversation_agent_id_service(conversation_id: int, agent_id: int, user_id: str) -> bool:
+ """
+ Update the latest agent associated with a conversation.
+ """
+ try:
+ success = update_conversation_agent_id(conversation_id, agent_id, user_id)
+ if not success:
+ raise Exception(f"Conversation {conversation_id} does not exist or has been deleted")
+ return True
+ except Exception as e:
+ logging.error(f"Failed to update conversation agent: {str(e)}")
+ raise Exception(str(e))
+
+
def rename_conversation_service(conversation_id: int, name: str, user_id: str) -> bool:
"""
Rename a conversation
@@ -560,6 +576,7 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List
formatted_history = {
# Convert to string
'conversation_id': str(history_data['conversation_id']),
+ 'agent_id': history_data.get('agent_id'),
'create_time': history_data['create_time'],
'message': messages
}
diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py
index 4ade6f211..648069ae9 100644
--- a/backend/utils/auth_utils.py
+++ b/backend/utils/auth_utils.py
@@ -314,15 +314,18 @@ def get_jwt_expiry_seconds(token: str) -> int:
if DEBUG_JWT_EXPIRE_SECONDS > 0:
return DEBUG_JWT_EXPIRE_SECONDS
- # Decode JWT token (without signature verification, only parse content)
- decoded = jwt.decode(jwt_token, options={"verify_signature": False})
+ # Decode JWT token with signature verification. Expiration validation is
+ # disabled intentionally because callers need the original exp/iat span.
+ decoded = _decode_jwt_token_for_expiry(jwt_token)
# Extract expiration time and issued time from JWT claims
- exp = decoded.get("exp", 0)
- iat = decoded.get("iat", 0)
+ exp = int(decoded["exp"])
+ iat = int(decoded["iat"])
# Calculate validity period (seconds)
expiry_seconds = exp - iat
+ if expiry_seconds <= 0:
+ raise ValueError("JWT exp must be greater than iat")
return expiry_seconds
except Exception as e:
@@ -348,6 +351,24 @@ def calculate_expires_at(token: Optional[str] = None) -> int:
return int((datetime.now() + timedelta(seconds=expiry_seconds)).timestamp())
+def _decode_jwt_token_for_expiry(token: str) -> dict:
+ """
+ Decode JWT claims for session timing after verifying the token signature.
+
+ Expiration validation is intentionally disabled so callers can compute the
+ original token lifetime even when the token is already expired.
+ """
+ if not SUPABASE_JWT_SECRET:
+ raise UnauthorizedError("JWT verification is not configured")
+
+ return jwt.decode(
+ token,
+ SUPABASE_JWT_SECRET,
+ algorithms=["HS256"],
+ options={"verify_exp": False, "verify_aud": False},
+ )
+
+
def _decode_jwt_token(authorization: str) -> dict:
"""
Extract user ID from JWT token after verifying signature and expiration.
diff --git a/deploy/sql/migrations/v2.3.0_0709_add_conversation_agent_id.sql b/deploy/sql/migrations/v2.3.0_0709_add_conversation_agent_id.sql
new file mode 100644
index 000000000..8f59d7544
--- /dev/null
+++ b/deploy/sql/migrations/v2.3.0_0709_add_conversation_agent_id.sql
@@ -0,0 +1,6 @@
+-- Store the latest agent used by each conversation so history selection can restore agent context.
+ALTER TABLE nexent.conversation_record_t
+ ADD COLUMN IF NOT EXISTS agent_id INTEGER;
+
+COMMENT ON COLUMN nexent.conversation_record_t.agent_id
+ IS 'Agent ID used by the latest run in this conversation';
diff --git a/frontend/app/[locale]/agents/components/AgentSelectorHeader.tsx b/frontend/app/[locale]/agents/components/AgentSelectorHeader.tsx
index 468a23634..0b68ee14e 100644
--- a/frontend/app/[locale]/agents/components/AgentSelectorHeader.tsx
+++ b/frontend/app/[locale]/agents/components/AgentSelectorHeader.tsx
@@ -378,8 +378,9 @@ export default function AgentSelectorHeader({
setCurrentAgent(null);
}
- // Refresh agent list
+ // Refresh agent lists
queryClient.invalidateQueries({ queryKey: ["agents"] });
+ queryClient.invalidateQueries({ queryKey: ["publishedAgentsList"] });
},
onError: () => {
message.error(t("businessLogic.config.error.agentDeleteFailed"));
diff --git a/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx b/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx
index 56add33b7..277be0154 100644
--- a/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx
+++ b/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx
@@ -348,8 +348,9 @@ export default function AgentList({
setCurrentAgent(null);
}
- // Refresh agent list
+ // Refresh agent lists
queryClient.invalidateQueries({ queryKey: ["agents"] });
+ queryClient.invalidateQueries({ queryKey: ["publishedAgentsList"] });
},
onError: () => {
message.error(t("businessLogic.config.error.agentDeleteFailed"));
diff --git a/frontend/app/[locale]/chat/internal/chatInterface.tsx b/frontend/app/[locale]/chat/internal/chatInterface.tsx
index 42f29e2d0..77050cc90 100644
--- a/frontend/app/[locale]/chat/internal/chatInterface.tsx
+++ b/frontend/app/[locale]/chat/internal/chatInterface.tsx
@@ -19,6 +19,7 @@ import {
convertImageUrlToApiUrl,
} from "@/services/storageService";
import { useConversationManagement } from "@/hooks/chat/useConversationManagement";
+import { usePublishedAgentList } from "@/hooks/agent/usePublishedAgentList";
import { ChatSidebar } from "../components/chatLeftSidebar";
import { FilePreview } from "@/types/chat";
@@ -39,6 +40,7 @@ import {
ApiConversationDetail,
HistoryItem,
} from "@/types/chat";
+import type { Agent } from "@/types/agentConfig";
import { ChatMessageType } from "@/types/chat";
import {
handleStreamResponse,
@@ -216,25 +218,87 @@ export function ChatInterface() {
const [agentModelIds, setAgentModelIds] = useState([]);
const [agentModelNames, setAgentModelNames] = useState([]);
const [selectedModelId, setSelectedModelId] = useState(null);
+ const { agents: publishedAgents = [] } = usePublishedAgentList() as {
+ agents: Agent[];
+ };
useEffect(() => {
sessionMessagesRef.current = sessionMessages;
}, [sessionMessages]);
- const handleAgentSelectWithGreeting = (
- agentId: string | null,
- greeting?: string,
- exampleQuestions?: string[],
- modelIds?: number[],
- modelNames?: string[]
- ) => {
- setSelectedAgentId(agentId);
- setAgentGreeting(greeting || null);
- setAgentExampleQuestions(exampleQuestions || []);
- setAgentModelIds(modelIds || []);
- setAgentModelNames(modelNames || []);
- setSelectedModelId(modelIds && modelIds.length > 0 ? modelIds[0] : null);
- };
+ const handleAgentSelectWithGreeting = useCallback(
+ (
+ agentId: string | null,
+ greeting?: string,
+ exampleQuestions?: string[],
+ modelIds?: number[],
+ modelNames?: string[]
+ ) => {
+ setSelectedAgentId(agentId);
+ setAgentGreeting(greeting || null);
+ setAgentExampleQuestions(exampleQuestions || []);
+ setAgentModelIds(modelIds || []);
+ setAgentModelNames(modelNames || []);
+ setSelectedModelId(modelIds && modelIds.length > 0 ? modelIds[0] : null);
+ },
+ []
+ );
+
+ const restoreConversationAgent = useCallback(
+ (agentId?: number | string | null) => {
+ if (agentId === undefined || agentId === null) {
+ handleAgentSelectWithGreeting(null);
+ return;
+ }
+
+ const normalizedAgentId = String(agentId);
+ const agent = publishedAgents.find((item) => item.id === normalizedAgentId);
+
+ if (!agent && publishedAgents.length === 0) {
+ setSelectedAgentId(normalizedAgentId);
+ setAgentGreeting(null);
+ setAgentExampleQuestions([]);
+ setAgentModelIds([]);
+ setAgentModelNames([]);
+ setSelectedModelId(null);
+ return;
+ }
+
+ if (!agent || agent.is_available === false) {
+ handleAgentSelectWithGreeting(null);
+ return;
+ }
+
+ handleAgentSelectWithGreeting(
+ normalizedAgentId,
+ agent.greeting_message,
+ agent.example_questions,
+ agent.model_ids,
+ agent.model_names
+ );
+ },
+ [handleAgentSelectWithGreeting, publishedAgents]
+ );
+
+ useEffect(() => {
+ if (!selectedAgentId || publishedAgents.length === 0) {
+ return;
+ }
+
+ const agent = publishedAgents.find((item) => item.id === selectedAgentId);
+ if (!agent || agent.is_available === false) {
+ handleAgentSelectWithGreeting(null);
+ return;
+ }
+
+ setAgentGreeting(agent.greeting_message || null);
+ setAgentExampleQuestions(agent.example_questions || []);
+ setAgentModelIds(agent.model_ids || []);
+ setAgentModelNames(agent.model_names || []);
+ setSelectedModelId(
+ agent.model_ids && agent.model_ids.length > 0 ? agent.model_ids[0] : null
+ );
+ }, [handleAgentSelectWithGreeting, publishedAgents, selectedAgentId]);
useEffect(() => {
const agentId = sessionStorage.getItem("selectedAgentId");
@@ -523,6 +587,8 @@ export function ChatInterface() {
}
// Send request to backend API, add signal parameter
+ const agentIdForRun =
+ selectedAgentId !== null ? Number(selectedAgentId) : null;
const runAgentParams: any = {
query: finalQuery, // Use preprocessed query or original query
history: currentMessages
@@ -579,8 +645,8 @@ export function ChatInterface() {
}
// Only add agent_id if it's not null
- if (selectedAgentId !== null) {
- runAgentParams.agent_id = Number(selectedAgentId);
+ if (agentIdForRun !== null) {
+ runAgentParams.agent_id = agentIdForRun;
}
// Add selected model_id for agent run
@@ -593,6 +659,13 @@ export function ChatInterface() {
currentController.signal
);
+ if (currentConversationId != null) {
+ conversationManagement.updateConversationAgentId(
+ currentConversationId,
+ agentIdForRun
+ );
+ }
+
if (!reader) throw new Error("Response body is null");
// Create dynamic setCurrentSessionMessages in handleSend function
@@ -710,7 +783,8 @@ export function ChatInterface() {
// appear in the conversation list during streaming (not only after stream ends)
conversationManagement.prependConversation(
conversationId,
- t("chatInterface.newConversation")
+ t("chatInterface.newConversation"),
+ agentIdForRun
);
},
false, // isDebug: false for normal chat mode
@@ -1075,6 +1149,7 @@ export function ChatInterface() {
// Use conversation management hook
conversationManagement.handleConversationSelect(dialog);
+ restoreConversationAgent(dialog.agent_id ?? null);
setSelectedMessageId(undefined);
setShowRightPanel(false);
@@ -1138,6 +1213,9 @@ export function ChatInterface() {
if (data.code === 0 && data.data && data.data.length > 0) {
const conversationData = data.data[0] as ApiConversationDetail;
+ restoreConversationAgent(
+ conversationData.agent_id ?? dialog.agent_id ?? null
+ );
const formattedMessages =
formatConversationMessagesFromResponse(conversationData, t);
@@ -1258,6 +1336,9 @@ export function ChatInterface() {
if (data.code === 0 && data.data && data.data.length > 0) {
const conversationData = data.data[0] as ApiConversationDetail;
+ restoreConversationAgent(
+ conversationData.agent_id ?? dialog.agent_id ?? null
+ );
const formattedMessages =
formatConversationMessagesFromResponse(conversationData, t);
diff --git a/frontend/hooks/agent/usePublishedAgentList.ts b/frontend/hooks/agent/usePublishedAgentList.ts
index 6b09fb826..ec6ce85e6 100644
--- a/frontend/hooks/agent/usePublishedAgentList.ts
+++ b/frontend/hooks/agent/usePublishedAgentList.ts
@@ -16,6 +16,7 @@ export function usePublishedAgentList() {
return res.data || [];
},
staleTime: 60_000,
+ refetchOnMount: "always",
enabled: true,
});
diff --git a/frontend/hooks/chat/useConversationManagement.ts b/frontend/hooks/chat/useConversationManagement.ts
index 4dbf6ea23..78607b3c6 100644
--- a/frontend/hooks/chat/useConversationManagement.ts
+++ b/frontend/hooks/chat/useConversationManagement.ts
@@ -22,7 +22,8 @@ export interface ConversationManagement {
conversationListQuery: UseQueryResult;
fetchConversationList: () => Promise;
invalidateConversationList: () => void;
- prependConversation: (conversationId: number, title: string) => void;
+ prependConversation: (conversationId: number, title: string, agentId?: number | null) => void;
+ updateConversationAgentId: (conversationId: number, agentId: number | null) => void;
handleNewConversation: () => void;
handleConversationSelect: (conversation: ConversationListItem) => Promise;
updateConversationTitle: (conversationId: number, title: string) => Promise;
@@ -78,7 +79,7 @@ export const useConversationManagement = (): ConversationManagement => {
// Prepend a newly created conversation to the sidebar list so it appears
// immediately (without waiting for a refetch).
const prependConversation = useCallback(
- (conversationId: number, title: string) => {
+ (conversationId: number, title: string, agentId?: number | null) => {
queryClient.setQueryData(
CONVERSATION_LIST_QUERY_KEY,
(prev) => {
@@ -91,6 +92,7 @@ export const useConversationManagement = (): ConversationManagement => {
const newItem: ConversationListItem = {
conversation_id: conversationId,
conversation_title: title,
+ agent_id: agentId ?? null,
create_time: now,
update_time: now,
};
@@ -101,6 +103,25 @@ export const useConversationManagement = (): ConversationManagement => {
[queryClient]
);
+ const updateConversationAgentId = useCallback(
+ (conversationId: number, agentId: number | null) => {
+ queryClient.setQueryData(
+ CONVERSATION_LIST_QUERY_KEY,
+ (prev) => {
+ if (!prev) {
+ return prev;
+ }
+ return prev.map((conversation) =>
+ conversation.conversation_id === conversationId
+ ? { ...conversation, agent_id: agentId }
+ : conversation
+ );
+ }
+ );
+ },
+ [queryClient]
+ );
+
// Handle conversation selection
const handleConversationSelect = async (conversation: ConversationListItem) => {
setSelectedConversationId(conversation.conversation_id);
@@ -153,6 +174,7 @@ export const useConversationManagement = (): ConversationManagement => {
fetchConversationList,
invalidateConversationList,
prependConversation,
+ updateConversationAgentId,
handleNewConversation,
handleConversationSelect,
updateConversationTitle,
diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index 097445775..4b2327a53 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -1640,10 +1640,10 @@
"memoryService.loadMemoryError": "Failed to load memories, please try again later",
"memoryService.tenantSharedGroupTitle": "Tenant shared memories",
- "memoryService.agentSharedGroupTitle": "{{AgentName}}",
+ "memoryService.agentSharedGroupTitle": "{{agentName}}",
"memoryService.agentSharedPlaceholder": "Agent Shared Memories",
"memoryService.userPersonalGroupTitle": "User's personal memories",
- "memoryService.userAgentGroupTitle": "{{AgentName}}",
+ "memoryService.userAgentGroupTitle": "{{agentName}}",
"memoryService.userAgentPlaceholder": "User Agent Memories",
"memoryManageModal.title": "Memory Management",
diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts
index 8d07b9932..edd5b9140 100644
--- a/frontend/types/chat.ts
+++ b/frontend/types/chat.ts
@@ -342,12 +342,14 @@ export interface ApiMessage {
export interface ApiConversationDetail {
create_time: number;
conversation_id: number;
+ agent_id?: number | null;
message: ApiMessage[];
}
export interface ConversationListItem {
conversation_id: number;
conversation_title: string;
+ agent_id?: number | null;
create_time: number;
update_time: number;
}
diff --git a/frontend/types/conversation.ts b/frontend/types/conversation.ts
index 650765eee..ec2cf2b99 100644
--- a/frontend/types/conversation.ts
+++ b/frontend/types/conversation.ts
@@ -1,6 +1,7 @@
export interface ConversationListItem {
conversation_id: number;
conversation_title: string;
+ agent_id?: number | null;
create_time: number;
update_time: number;
}
@@ -33,6 +34,7 @@ export interface ApiMessage {
export interface ApiConversationDetail {
create_time: number;
conversation_id: number;
+ agent_id?: number | null;
message: ApiMessage[];
}
diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py
index fc86c8b06..e787a843f 100644
--- a/test/backend/database/test_conversation_db.py
+++ b/test/backend/database/test_conversation_db.py
@@ -101,6 +101,7 @@ def _values_side_effect(*args, **kwargs):
class ConversationRecord:
conversation_id = MagicMock(name="ConversationRecord.conversation_id")
conversation_title = MagicMock(name="ConversationRecord.conversation_title")
+ agent_id = MagicMock(name="ConversationRecord.agent_id")
create_time = MagicMock(name="ConversationRecord.create_time")
update_time = MagicMock(name="ConversationRecord.update_time")
created_by = MagicMock(name="ConversationRecord.created_by")
@@ -206,6 +207,7 @@ def _add_update_tracking(data, user_id):
get_source_searches_by_message,
rename_conversation,
soft_delete_all_conversations_by_user,
+ update_conversation_agent_id,
update_conversation_message_content,
update_conversation_message_status,
update_message_minio_files,
@@ -479,19 +481,22 @@ def test_create_conversation_success(monkeypatch, mock_session_ctx):
mock_record = MagicMock()
mock_record.conversation_id = 42
mock_record.conversation_title = "Test Title"
+ mock_record.agent_id = 7
mock_record.create_time = 1234567890.123
mock_record.update_time = 1234567890.456
session.execute.return_value.fetchone.return_value = mock_record
monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx)
- result = create_conversation("Test Title", user_id="user-1")
+ result = create_conversation("Test Title", user_id="user-1", agent_id=7)
assert result["conversation_id"] == 42
assert result["conversation_title"] == "Test Title"
+ assert result["agent_id"] == 7
assert result["create_time"] == 1234567890
assert result["update_time"] == 1234567890
session.execute.assert_called_once()
+ assert _captured_insert_values["agent_id"] == 7
def test_create_conversation_without_user_id(monkeypatch, mock_session_ctx):
@@ -500,6 +505,7 @@ def test_create_conversation_without_user_id(monkeypatch, mock_session_ctx):
mock_record = MagicMock()
mock_record.conversation_id = 1
mock_record.conversation_title = "No User Title"
+ mock_record.agent_id = None
mock_record.create_time = 1000.0
mock_record.update_time = 1000.0
session.execute.return_value.fetchone.return_value = mock_record
@@ -928,8 +934,12 @@ def test_get_conversation_list(monkeypatch, mock_session_ctx):
"""get_conversation_list returns all conversations ordered by create_time desc."""
session, ctx = mock_session_ctx
mock_records = [
- MagicMock(conversation_id=2, conversation_title="Second", create_time=2000.0, update_time=2000.0),
- MagicMock(conversation_id=1, conversation_title="First", create_time=1000.0, update_time=1000.0),
+ MagicMock(
+ conversation_id=2, conversation_title="Second", agent_id=22, create_time=2000.0, update_time=2000.0
+ ),
+ MagicMock(
+ conversation_id=1, conversation_title="First", agent_id=11, create_time=1000.0, update_time=1000.0
+ ),
]
session.execute.return_value = iter(mock_records)
@@ -937,6 +947,7 @@ def as_dict_side_effect(record):
return {
"conversation_id": record.conversation_id,
"conversation_title": record.conversation_title,
+ "agent_id": record.agent_id,
"create_time": record.create_time,
"update_time": record.update_time,
}
@@ -948,18 +959,24 @@ def as_dict_side_effect(record):
assert len(result) == 2
assert result[0]["conversation_id"] == 2
+ assert result[0]["agent_id"] == 22
def test_get_conversation_list_filtered_by_user(monkeypatch, mock_session_ctx):
"""get_conversation_list filters by user_id when provided."""
session, ctx = mock_session_ctx
- mock_records = [MagicMock(conversation_id=1, conversation_title="User Chat", create_time=1000.0, update_time=1000.0)]
+ mock_records = [
+ MagicMock(
+ conversation_id=1, conversation_title="User Chat", agent_id=15, create_time=1000.0, update_time=1000.0
+ )
+ ]
session.execute.return_value = iter(mock_records)
def as_dict_side_effect(record):
return {
"conversation_id": record.conversation_id,
"conversation_title": record.conversation_title,
+ "agent_id": record.agent_id,
"create_time": record.create_time,
"update_time": record.update_time,
}
@@ -970,6 +987,37 @@ def as_dict_side_effect(record):
result = get_conversation_list(user_id="specific-user")
assert len(result) == 1
+ assert result[0]["agent_id"] == 15
+
+
+def test_update_conversation_agent_id_success(monkeypatch, mock_session_ctx):
+ """update_conversation_agent_id updates the latest agent and returns True."""
+ session, ctx = mock_session_ctx
+ update_result = MagicMock()
+ update_result.rowcount = 1
+ session.execute.return_value = update_result
+
+ monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx)
+
+ result = update_conversation_agent_id("123", "45", user_id="user-1")
+
+ assert result is True
+ session.execute.assert_called_once()
+
+
+def test_update_conversation_agent_id_not_found(monkeypatch, mock_session_ctx):
+ """update_conversation_agent_id returns False when no row is updated."""
+ session, ctx = mock_session_ctx
+ update_result = MagicMock()
+ update_result.rowcount = 0
+ session.execute.return_value = update_result
+
+ monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx)
+
+ result = update_conversation_agent_id(123, 45)
+
+ assert result is False
+ session.execute.assert_called_once()
# =============================================================================
@@ -1867,7 +1915,7 @@ def test_get_conversation_history_with_messages(monkeypatch, mock_session_ctx):
session, ctx = mock_session_ctx
# Use SimpleNamespace for accurate attribute checks
- mock_conv = SimpleNamespace(conversation_id=1, create_time=1000.0)
+ mock_conv = SimpleNamespace(conversation_id=1, agent_id=9, create_time=1000.0)
mock_message = SimpleNamespace(
message_id=1,
message_index=0,
@@ -1909,7 +1957,11 @@ def as_dict_side_effect(record):
"units": getattr(record, 'units', None),
}
elif hasattr(record, 'conversation_id'):
- return {"conversation_id": record.conversation_id, "create_time": record.create_time}
+ return {
+ "conversation_id": record.conversation_id,
+ "agent_id": record.agent_id,
+ "create_time": record.create_time,
+ }
return {}
monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx)
@@ -1919,6 +1971,7 @@ def as_dict_side_effect(record):
assert result is not None
assert result['conversation_id'] == 1
+ assert result['agent_id'] == 9
def test_create_message_units_creates_all_units_with_user_id(monkeypatch):
diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py
index cb3ef3f98..ced5b9c92 100644
--- a/test/backend/services/test_conversation_management_service.py
+++ b/test/backend/services/test_conversation_management_service.py
@@ -213,6 +213,7 @@ def validate(self): pass
get_conversation_history_service,
get_sources_service,
generate_conversation_title_service,
+ update_conversation_agent_id_service,
update_message_opinion_service,
get_message_id_by_index_impl
)
@@ -446,7 +447,65 @@ def test_create_new_conversation(self, mock_create_conversation):
self.assertEqual(result["conversation_id"], 123)
self.assertEqual(result["title"], "New Chat")
mock_create_conversation.assert_called_once_with(
- "New Chat", self.user_id)
+ "New Chat", self.user_id, agent_id=None)
+
+ @patch('backend.services.conversation_management_service.create_conversation')
+ def test_create_new_conversation_with_agent_id(self, mock_create_conversation):
+ # Setup
+ mock_create_conversation.return_value = {
+ "conversation_id": 123,
+ "title": "New Chat",
+ "agent_id": 7,
+ "create_time": "2023-04-01"
+ }
+
+ # Execute
+ result = create_new_conversation("New Chat", self.user_id, agent_id=7)
+
+ # Assert
+ self.assertEqual(result["conversation_id"], 123)
+ self.assertEqual(result["agent_id"], 7)
+ mock_create_conversation.assert_called_once_with(
+ "New Chat", self.user_id, agent_id=7)
+
+ @patch('backend.services.conversation_management_service.update_conversation_agent_id')
+ def test_update_conversation_agent_id_service_success(self, mock_update_conversation_agent_id):
+ # Setup
+ mock_update_conversation_agent_id.return_value = True
+
+ # Execute
+ result = update_conversation_agent_id_service(123, 7, self.user_id)
+
+ # Assert
+ self.assertTrue(result)
+ mock_update_conversation_agent_id.assert_called_once_with(
+ 123, 7, self.user_id)
+
+ @patch('backend.services.conversation_management_service.update_conversation_agent_id')
+ def test_update_conversation_agent_id_service_not_found(self, mock_update_conversation_agent_id):
+ # Setup
+ mock_update_conversation_agent_id.return_value = False
+
+ # Execute / Assert
+ with self.assertRaises(Exception) as context:
+ update_conversation_agent_id_service(123, 7, self.user_id)
+
+ self.assertIn("Conversation 123 does not exist", str(context.exception))
+ mock_update_conversation_agent_id.assert_called_once_with(
+ 123, 7, self.user_id)
+
+ @patch('backend.services.conversation_management_service.update_conversation_agent_id')
+ def test_update_conversation_agent_id_service_database_error(self, mock_update_conversation_agent_id):
+ # Setup
+ mock_update_conversation_agent_id.side_effect = Exception("database down")
+
+ # Execute / Assert
+ with self.assertRaises(Exception) as context:
+ update_conversation_agent_id_service(123, 7, self.user_id)
+
+ self.assertEqual(str(context.exception), "database down")
+ mock_update_conversation_agent_id.assert_called_once_with(
+ 123, 7, self.user_id)
@patch('backend.services.conversation_management_service.get_conversation_list')
def test_get_conversation_list_service(self, mock_get_conversation_list):
@@ -494,6 +553,7 @@ def test_get_conversation_history_service(self, mock_get_conversation_history):
# Setup
mock_history = {
"conversation_id": 123,
+ "agent_id": 7,
"create_time": "2023-04-01",
"message_records": [
{
@@ -523,6 +583,7 @@ def test_get_conversation_history_service(self, mock_get_conversation_history):
self.assertEqual(len(result), 1) # Result is wrapped in a list
self.assertEqual(result[0]["conversation_id"],
"123") # Converted to string
+ self.assertEqual(result[0]["agent_id"], 7)
self.assertEqual(len(result[0]["message"]), 2)
# Check message structure
user_message = result[0]["message"][0]
diff --git a/test/backend/utils/test_auth_utils.py b/test/backend/utils/test_auth_utils.py
index e9ea7a377..dec6a6152 100644
--- a/test/backend/utils/test_auth_utils.py
+++ b/test/backend/utils/test_auth_utils.py
@@ -297,10 +297,79 @@ def test_generate_test_jwt_and_get_expiry_seconds(monkeypatch):
# ensure not in speed mode and no DEBUG_JWT_EXPIRE_SECONDS was set for this test
monkeypatch.setattr(au, "IS_SPEED_MODE", False)
monkeypatch.setattr(au, "DEBUG_JWT_EXPIRE_SECONDS", 0)
+ monkeypatch.setattr(au, "SUPABASE_JWT_SECRET", au.MOCK_JWT_SECRET_KEY)
seconds = au.get_jwt_expiry_seconds(token)
assert seconds == 1234
+def test_get_jwt_expiry_seconds_rejects_forged_far_future_token(monkeypatch):
+ """Expiry seconds must not trust JWT claims from tokens with invalid signatures."""
+ now = int(time.time())
+ forged_token = au.jwt.encode(
+ {
+ "sub": "user-1",
+ "iat": now,
+ "exp": now + 10 * 365 * 24 * 60 * 60,
+ "aud": "nexent-api",
+ },
+ "attacker-secret",
+ algorithm="HS256",
+ )
+
+ monkeypatch.setattr(au, "IS_SPEED_MODE", False)
+ monkeypatch.setattr(au, "DEBUG_JWT_EXPIRE_SECONDS", 0)
+ monkeypatch.setattr(au, "SUPABASE_JWT_SECRET", au.MOCK_JWT_SECRET_KEY)
+
+ seconds = au.get_jwt_expiry_seconds(forged_token)
+
+ assert seconds == 3600
+
+
+def test_get_jwt_expiry_seconds_uses_debug_override(monkeypatch):
+ monkeypatch.setattr(au, "IS_SPEED_MODE", False)
+ monkeypatch.setattr(au, "DEBUG_JWT_EXPIRE_SECONDS", 77)
+
+ assert au.get_jwt_expiry_seconds("Bearer ignored-token") == 77
+
+
+def test_get_jwt_expiry_seconds_rejects_non_positive_lifetime(monkeypatch):
+ now = int(time.time())
+ token = au.jwt.encode(
+ {
+ "sub": "user-1",
+ "iat": now,
+ "exp": now,
+ "aud": "nexent-api",
+ },
+ au.MOCK_JWT_SECRET_KEY,
+ algorithm="HS256",
+ )
+
+ monkeypatch.setattr(au, "IS_SPEED_MODE", False)
+ monkeypatch.setattr(au, "DEBUG_JWT_EXPIRE_SECONDS", 0)
+ monkeypatch.setattr(au, "SUPABASE_JWT_SECRET", au.MOCK_JWT_SECRET_KEY)
+
+ assert au.get_jwt_expiry_seconds(token) == 3600
+
+
+def test_decode_jwt_token_for_expiry_requires_secret(monkeypatch):
+ monkeypatch.setattr(au, "SUPABASE_JWT_SECRET", "")
+
+ with pytest.raises(UnauthorizedError, match="JWT verification is not configured"):
+ au._decode_jwt_token_for_expiry("any-token")
+
+
+def test_calculate_expires_at_uses_verified_token_lifetime(monkeypatch):
+ token = au.generate_test_jwt("user-1", expires_in=120)
+ monkeypatch.setattr(au, "IS_SPEED_MODE", False)
+ monkeypatch.setattr(au, "DEBUG_JWT_EXPIRE_SECONDS", 0)
+ monkeypatch.setattr(au, "SUPABASE_JWT_SECRET", au.MOCK_JWT_SECRET_KEY)
+
+ expires_at = au.calculate_expires_at(token)
+
+ assert int(time.time()) + 60 <= expires_at <= int(time.time()) + 180
+
+
def test_calculate_expires_at_speed_mode(monkeypatch):
monkeypatch.setattr(au, "IS_SPEED_MODE", True)
exp = au.calculate_expires_at("irrelevant")
From 4a48c18dcae2c7b9c340394c12524765f7c02107 Mon Sep 17 00:00:00 2001
From: panyehong <91180085+YehongPan@users.noreply.github.com>
Date: Fri, 10 Jul 2026 15:37:40 +0800
Subject: [PATCH 18/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Remove?=
=?UTF-8?q?=20the=20limit=20on=20the=20maximum=20number=20of=20execution?=
=?UTF-8?q?=20steps=20for=20the=20agent.=20(#3400)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* ♻️ Refactor: Remove the limit on the maximum number of execution steps for the agent.
* ♻️ Refactor: Remove the limit on the maximum number of execution steps for the agent.
* ♻️ Refactor: Remove the limit on the maximum number of execution steps for the agent.
---
backend/consts/model.py | 2 +-
backend/services/agent_service.py | 4 +--
.../agentInfo/AgentGenerateDetail.tsx | 2 --
sdk/nexent/core/agents/agent_model.py | 2 +-
test/backend/services/test_agent_service.py | 2 +-
test/sdk/core/agents/test_agent_model.py | 29 ++++++++++++-------
6 files changed, 23 insertions(+), 18 deletions(-)
diff --git a/backend/consts/model.py b/backend/consts/model.py
index 9d93e58b5..e8061b4f8 100644
--- a/backend/consts/model.py
+++ b/backend/consts/model.py
@@ -547,7 +547,7 @@ class AgentInfoRequest(BaseModel):
business_description: Optional[str] = None
author: Optional[str] = None
model_ids: Optional[List[int]] = None
- max_steps: Optional[int] = Field(default=None, ge=1, le=30)
+ max_steps: Optional[int] = Field(default=None, ge=1)
requested_output_tokens: Optional[int] = Field(default=None, gt=0)
provide_run_summary: Optional[bool] = None
duty_prompt: Optional[str] = None
diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py
index b6b7151b8..1ebd9b0af 100644
--- a/backend/services/agent_service.py
+++ b/backend/services/agent_service.py
@@ -2242,9 +2242,9 @@ async def import_agent_by_agent_id(
enabled=True,
params=tool.params))
# check the validity of the agent parameters
- if import_agent_info.max_steps <= 0 or import_agent_info.max_steps > 30:
+ if import_agent_info.max_steps <= 0:
raise ValueError(
- f"Invalid max steps: {import_agent_info.max_steps}. max steps must be greater than 0 and less than 30.")
+ f"Invalid max steps: {import_agent_info.max_steps}. max steps must be greater than 0.")
if not import_agent_info.name.isidentifier():
raise ValueError(
f"Invalid agent name: {import_agent_info.name}. agent name must be a valid python variable name.")
diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
index 7e6d8bb92..965b7b4a9 100644
--- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
+++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
@@ -1046,14 +1046,12 @@ export default function AgentGenerateDetail({}) {
{
type: "number",
min: 1,
- max: 30,
message: t("businessLogic.config.maxSteps"),
},
]}
>
{
const value = form.getFieldValue("mainAgentMaxStep");
diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py
index 7da3048a3..4b0fe41a4 100644
--- a/sdk/nexent/core/agents/agent_model.py
+++ b/sdk/nexent/core/agents/agent_model.py
@@ -200,7 +200,7 @@ class AgentConfig(BaseModel):
description: str = Field(description="Agent description")
prompt_templates: Optional[Dict[str, Any]] = Field(description="Prompt templates", default=None)
tools: List[ToolConfig] = Field(description="List of tool information")
- max_steps: int = Field(description="Maximum number of steps for current Agent", default=15, ge=1, le=30)
+ max_steps: int = Field(description="Maximum number of steps for current Agent", default=15, ge=1)
requested_output_tokens: Optional[int] = Field(
description=(
"Per-agent W2 output reserve override. None means inherit the "
diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py
index df26f9d0f..2b22b7a55 100644
--- a/test/backend/services/test_agent_service.py
+++ b/test/backend/services/test_agent_service.py
@@ -10863,7 +10863,7 @@ async def test_import_agent_by_agent_id_invalid_max_steps():
mock_agent_info.description = "desc"
mock_agent_info.business_description = "biz"
mock_agent_info.author = "author"
- mock_agent_info.max_steps = 35 # Too high (> 30)
+ mock_agent_info.max_steps = -1 # Invalid: must be > 0
mock_agent_info.provide_run_summary = True
mock_agent_info.duty_prompt = "duty"
mock_agent_info.constraint_prompt = "constraint"
diff --git a/test/sdk/core/agents/test_agent_model.py b/test/sdk/core/agents/test_agent_model.py
index 81b86e2a3..a75c61a7c 100644
--- a/test/sdk/core/agents/test_agent_model.py
+++ b/test/sdk/core/agents/test_agent_model.py
@@ -1238,17 +1238,8 @@ def test_agent_config_max_steps_boundary(self):
)
assert config_max.max_steps == 30
- def test_agent_config_max_steps_rejects_out_of_bounds(self):
- """Test AgentConfig rejects max_steps values outside 1-30 range."""
- with pytest.raises(Exception):
- agent_model_module.AgentConfig(
- name="too_high",
- description="Too high steps",
- tools=[],
- model_name="test",
- max_steps=31
- )
-
+ def test_agent_config_max_steps_rejects_below_lower_bound(self):
+ """Test AgentConfig rejects max_steps values below the lower bound (1)."""
with pytest.raises(Exception):
agent_model_module.AgentConfig(
name="too_low",
@@ -1258,6 +1249,22 @@ def test_agent_config_max_steps_rejects_out_of_bounds(self):
max_steps=0
)
+ def test_agent_config_max_steps_accepts_above_design_max(self):
+ """Test AgentConfig accepts max_steps values above the design-maximum (30).
+
+ The model enforces ge=1 (lower bound) but does not enforce an upper bound.
+ Values above 30 are silently accepted; callers are responsible for enforcing
+ application-level limits if needed.
+ """
+ config = agent_model_module.AgentConfig(
+ name="above_design_max",
+ description="Steps above 30",
+ tools=[],
+ model_name="test",
+ max_steps=31
+ )
+ assert config.max_steps == 31
+
class TestAgentVerificationConfig:
"""Tests for layered ReAct verification configuration."""
From 3ee20b44a0581976672464237b51730e279e87e8 Mon Sep 17 00:00:00 2001
From: Jason Wang <56037774+JasonW404@users.noreply.github.com>
Date: Mon, 13 Jul 2026 09:42:06 +0800
Subject: [PATCH 19/26] fix context manager builtin tool context (#3401)
---
backend/agents/create_agent_info.py | 6 ++--
test/backend/agents/test_create_agent_info.py | 28 +++++++++++++++++++
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py
index 5ed4a9b1c..e5708904d 100644
--- a/backend/agents/create_agent_info.py
+++ b/backend/agents/create_agent_info.py
@@ -862,12 +862,14 @@ async def create_agent_config(
skills = _get_skills_for_template(agent_id, tenant_id, version_no)
is_manager = len(managed_agents) > 0 or len(external_a2a_agents) > 0
+ builtin_tools = _get_skill_script_tools(agent_id, tenant_id, version_no)
+ available_tools = tool_list + builtin_tools
render_kwargs = {
"duty": duty_prompt,
"constraint": constraint_prompt,
"few_shots": few_shots_prompt,
- "tools": {tool.name: tool for tool in tool_list},
+ "tools": {tool.name: tool for tool in available_tools},
"skills": skills,
"managed_agents": {agent.name: agent for agent in managed_agents},
"external_a2a_agents": {agent.agent_id: agent for agent in external_a2a_agents},
@@ -969,7 +971,7 @@ async def create_agent_config(
language=language,
agent_id=agent_id
),
- tools=tool_list + _get_skill_script_tools(agent_id, tenant_id, version_no),
+ tools=available_tools,
max_steps=agent_info.get("max_steps", 15),
requested_output_tokens=requested_output_tokens,
model_name=model_name,
diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py
index 940a26c56..56a96ca11 100644
--- a/test/backend/agents/test_create_agent_info.py
+++ b/test/backend/agents/test_create_agent_info.py
@@ -1834,6 +1834,34 @@ async def test_create_agent_config_managed_path_uses_raw_components_not_legacy_p
assert mocks["agent_config"].call_args.kwargs["context_components"] is components
assert mocks["agent_config"].call_args.kwargs["context_manager_config"].enabled is True
+ @pytest.mark.asyncio
+ async def test_create_agent_config_managed_path_includes_builtin_tools_in_context(self):
+ """Managed path should describe the same builtin tools that AgentConfig exposes."""
+ builtin_tools = [
+ types.SimpleNamespace(name="run_skill_script"),
+ types.SimpleNamespace(name="read_skill_md"),
+ types.SimpleNamespace(name="read_skill_config"),
+ types.SimpleNamespace(name="write_skill_file"),
+ ]
+ with patch(
+ 'backend.agents.create_agent_info._get_skill_script_tools',
+ return_value=builtin_tools,
+ ):
+ mocks = await self._run_context_manager_case(
+ enable_context_manager=True,
+ template="legacy {{duty}}",
+ prepared_prompt="",
+ )
+
+ context_tools = mocks["build_components"].call_args.kwargs["tools"]
+ agent_tools = mocks["agent_config"].call_args.kwargs["tools"]
+
+ assert "run_skill_script" in context_tools
+ assert "read_skill_md" in context_tools
+ assert "read_skill_config" in context_tools
+ assert "write_skill_file" in context_tools
+ assert set(context_tools) == {tool.name for tool in agent_tools}
+
@pytest.mark.asyncio
async def test_create_agent_config_legacy_path_renders_prompt_and_skips_components(self):
"""Legacy path should render the Jinja prompt and not build managed components."""
From 20bad41e852474f5d66a2c270edc64ef048dc21c Mon Sep 17 00:00:00 2001
From: hhhhsc701 <56435672+hhhhsc701@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:06:44 +0800
Subject: [PATCH 20/26] =?UTF-8?q?=E6=94=AF=E6=8C=81web=20config=20runtime?=
=?UTF-8?q?=20northbound=E5=A4=9A=E5=89=AF=E6=9C=AC=20(#3390)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 支持web config runtime northbound多副本
* Use ReadWriteMany for Kubernetes persistence defaults
* Normalize PR diff line endings
* fix: 修复单测
* Fix agent service unit test mocks
* feat: 默认单实例
* feat: 补充单测
* feat: 补充单测
* feat: enhance deployment options persistence and loading mechanism
* feat: remove multi-replica mode references and improve error logging in northbound service
---------
Co-authored-by: root
---
.github/workflows/build-offline-package.yml | 1 +
backend/agents/agent_run_manager.py | 11 +-
backend/consts/const.py | 10 +
backend/services/agent_service.py | 148 ++-
backend/services/northbound_service.py | 52 +-
backend/services/runtime_state_service.py | 319 +++++
backend/services/streaming_channel.py | 13 +
deploy/k8s/deploy.sh | 111 +-
.../nexent-common/templates/configmap.yaml | 12 +-
.../templates/shared-storage.yaml | 2 +-
.../nexent/charts/nexent-common/values.yaml | 12 +
.../nexent-config/templates/deployment.yaml | 2 +
.../nexent/charts/nexent-config/values.yaml | 6 +
.../charts/nexent-elasticsearch/values.yaml | 2 +-
.../nexent/charts/nexent-minio/values.yaml | 2 +-
.../nexent-monitoring/templates/_helpers.tpl | 2 +-
.../charts/nexent-monitoring/values.yaml | 2 +-
.../templates/deployment.yaml | 2 +
.../charts/nexent-northbound/values.yaml | 6 +
.../charts/nexent-postgresql/values.yaml | 2 +-
.../nexent/charts/nexent-redis/values.yaml | 2 +-
.../nexent-runtime/templates/deployment.yaml | 2 +
.../nexent/charts/nexent-runtime/values.yaml | 6 +
.../charts/nexent-supabase-db/values.yaml | 2 +-
.../nexent-web/templates/deployment.yaml | 8 +
.../helm/nexent/charts/nexent-web/values.yaml | 9 +
deploy/k8s/helm/nexent/values.yaml | 6 +-
deploy/tests/test_common.sh | 17 +-
.../kubernetes-multi-replica-design.md | 1029 +++++++++++++++++
frontend/server.js | 41 +-
test/backend/services/test_agent_service.py | 493 +++++++-
.../services/test_northbound_service.py | 130 ++-
.../services/test_runtime_state_service.py | 444 +++++++
33 files changed, 2832 insertions(+), 74 deletions(-)
create mode 100644 backend/services/runtime_state_service.py
create mode 100644 doc/docs/zh/deployment/kubernetes-multi-replica-design.md
create mode 100644 test/backend/services/test_runtime_state_service.py
diff --git a/.github/workflows/build-offline-package.yml b/.github/workflows/build-offline-package.yml
index 4c72cecc4..54a6c4caf 100644
--- a/.github/workflows/build-offline-package.yml
+++ b/.github/workflows/build-offline-package.yml
@@ -117,6 +117,7 @@ jobs:
name: ${{ steps.set-vars.outputs.package-name }}
path: ./offline-output
if-no-files-found: error
+ include-hidden-files: true
retention-days: 30
- name: Summary
diff --git a/backend/agents/agent_run_manager.py b/backend/agents/agent_run_manager.py
index eca8c2fa4..33703fafa 100644
--- a/backend/agents/agent_run_manager.py
+++ b/backend/agents/agent_run_manager.py
@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING, Any, Dict, Union
from nexent.core.agents.agent_model import AgentRunInfo
+from services.runtime_state_service import runtime_state_service
if TYPE_CHECKING:
from nexent.core.agents.agent_context import ContextManager, ContextManagerConfig
@@ -45,8 +46,9 @@ def register_agent_run(self, conversation_id: Union[int, str], agent_run_info, u
self._conversation_run_counts[conv_key] = self._conversation_run_counts.get(conv_key, 0) + 1
logger.info(
f"register agent run instance, user_id: {user_id}, conversation_id: {conversation_id}")
+ runtime_state_service.register_run(user_id=user_id, conversation_id=conversation_id)
- def unregister_agent_run(self, conversation_id: Union[int, str], user_id: str):
+ def unregister_agent_run(self, conversation_id: Union[int, str], user_id: str, status: str = "completed"):
"""unregister agent run instance"""
with self._lock:
run_key = self._get_run_key(conversation_id, user_id)
@@ -61,6 +63,7 @@ def unregister_agent_run(self, conversation_id: Union[int, str], user_id: str):
else:
logger.info(
f"no agent run instance found for user_id: {user_id}, conversation_id: {conversation_id}")
+ runtime_state_service.mark_run_finished(user_id=user_id, conversation_id=conversation_id, status=status)
def get_agent_run_info(self, conversation_id: Union[int, str], user_id: str):
"""get agent run instance"""
@@ -69,13 +72,17 @@ def get_agent_run_info(self, conversation_id: Union[int, str], user_id: str):
def stop_agent_run(self, conversation_id: Union[int, str], user_id: str) -> bool:
"""stop agent run for specified conversation_id and user_id"""
+ remote_signal_set = runtime_state_service.set_cancel_signal(
+ user_id=user_id,
+ conversation_id=conversation_id,
+ )
agent_run_info = self.get_agent_run_info(conversation_id, user_id)
if agent_run_info is not None:
agent_run_info.stop_event.set()
logger.info(
f"agent run stopped, user_id: {user_id}, conversation_id: {conversation_id}")
return True
- return False
+ return remote_signal_set
def get_or_create_context_manager(
self,
diff --git a/backend/consts/const.py b/backend/consts/const.py
index cf950d109..a3ccb34ff 100644
--- a/backend/consts/const.py
+++ b/backend/consts/const.py
@@ -204,6 +204,16 @@ class VectorDatabaseType(str, Enum):
REDIS_URL = os.getenv("REDIS_URL")
REDIS_BACKEND_URL = os.getenv("REDIS_BACKEND_URL")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
+RUNTIME_STATE_REDIS_URL = os.getenv("RUNTIME_STATE_REDIS_URL") or REDIS_URL
+RUNTIME_STREAM_TTL_SECONDS = int(os.getenv("RUNTIME_STREAM_TTL_SECONDS", "86400"))
+RUNTIME_STREAM_MAX_LEN = int(os.getenv("RUNTIME_STREAM_MAX_LEN", "10000"))
+RUNTIME_RUN_TTL_SECONDS = int(os.getenv("RUNTIME_RUN_TTL_SECONDS", "86400"))
+RUNTIME_CANCEL_TTL_SECONDS = int(os.getenv("RUNTIME_CANCEL_TTL_SECONDS", "86400"))
+RUNTIME_COMPLETED_TTL_SECONDS = int(os.getenv("RUNTIME_COMPLETED_TTL_SECONDS", "300"))
+RUNTIME_CANCEL_POLL_INTERVAL_SECONDS = float(os.getenv("RUNTIME_CANCEL_POLL_INTERVAL_SECONDS", "1.0"))
+NORTHBOUND_IDEMPOTENCY_TTL_SECONDS = int(os.getenv("NORTHBOUND_IDEMPOTENCY_TTL_SECONDS", "600"))
+NORTHBOUND_RATE_LIMIT_ENABLED = os.getenv("NORTHBOUND_RATE_LIMIT_ENABLED", "true").lower() == "true"
+NORTHBOUND_RATE_LIMIT_PER_MINUTE = int(os.getenv("NORTHBOUND_RATE_LIMIT_PER_MINUTE", "120"))
FLOWER_PORT = int(os.getenv("FLOWER_PORT", "5555"))
DP_REDIS_CHUNKS_WAIT_TIMEOUT_S = int(
os.getenv("DP_REDIS_CHUNKS_WAIT_TIMEOUT_S", "30"))
diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py
index 1ebd9b0af..f14c4995f 100644
--- a/backend/services/agent_service.py
+++ b/backend/services/agent_service.py
@@ -23,7 +23,7 @@
from utils.prompt_template_utils import normalize_prompt_generate_template_content
from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, \
LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_PRIVATE, STREAM_STATUS_EVENT, \
- DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE
+ DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE, RUNTIME_CANCEL_POLL_INTERVAL_SECONDS
from consts.exceptions import AppException, MemoryPreparationException, SkillDuplicateError
from consts.error_code import ErrorCode
from consts.agent_unavailable_reasons import AgentUnavailableReason
@@ -107,6 +107,7 @@
)
from services.memory_config_service import build_memory_context
from services.streaming_channel import streaming_channel_manager
+from services.runtime_state_service import runtime_state_service
from utils.auth_utils import get_current_user_info, get_user_language
from utils.config_utils import tenant_config_manager
from utils.memory_utils import build_memory_config
@@ -133,6 +134,34 @@ async def _cleanup_channel_later(conversation_id: int, user_id: str, delay: floa
await streaming_channel_manager.remove_channel(conversation_id, user_id)
+async def _poll_runtime_cancel_signal(conversation_id: int, user_id: str, stop_event) -> None:
+ """Mirror Redis cancel signal into the local agent stop_event."""
+ while not stop_event.is_set():
+ if await runtime_state_service.is_cancelled_async(user_id=user_id, conversation_id=conversation_id):
+ stop_event.set()
+ logger.info(
+ "Runtime cancel signal received, user_id=%s, conversation_id=%s",
+ user_id,
+ conversation_id,
+ )
+ return
+ await asyncio.sleep(RUNTIME_CANCEL_POLL_INTERVAL_SECONDS)
+
+
+async def _cancel_task_on_runtime_signal(conversation_id: int, user_id: str, task: asyncio.Task) -> None:
+ """Cancel a local asyncio task when another Pod writes the runtime cancel signal."""
+ while not task.done():
+ if await runtime_state_service.is_cancelled_async(user_id=user_id, conversation_id=conversation_id):
+ task.cancel()
+ logger.info(
+ "Runtime cancel signal cancelled task, user_id=%s, conversation_id=%s",
+ user_id,
+ conversation_id,
+ )
+ return
+ await asyncio.sleep(RUNTIME_CANCEL_POLL_INTERVAL_SECONDS)
+
+
def _extract_json_objects_from_text(text: str) -> list[dict]:
"""Extract all JSON objects embedded in a text blob."""
if not text:
@@ -934,6 +963,14 @@ async def _stream_agent_chunks(
user_id=user_id
)
+ cancel_poll_task = asyncio.create_task(
+ _poll_runtime_cancel_signal(
+ conversation_id=agent_request.conversation_id,
+ user_id=user_id,
+ stop_event=agent_run_info.stop_event,
+ )
+ )
+
# In resume mode, emit a status event first
if is_resume_mode:
await channel.publish(STREAM_STATUS_EVENT)
@@ -1196,7 +1233,8 @@ async def _stream_agent_chunks(
except Exception:
logger.exception("Failed to mark last unit as completed")
- terminal_status = "completed" if stream_completed_normally else "failed"
+ was_stopped = getattr(agent_run_info, "stop_event", None) and agent_run_info.stop_event.is_set()
+ terminal_status = "stopped" if was_stopped else "completed" if stream_completed_normally else "failed"
try:
update_message_status(
streaming_message_id,
@@ -1206,12 +1244,17 @@ async def _stream_agent_chunks(
except Exception:
logger.exception("Failed to mark assistant message as %s", terminal_status)
+ if not cancel_poll_task.done():
+ cancel_poll_task.cancel()
+
+ was_stopped = getattr(agent_run_info, "stop_event", None) and agent_run_info.stop_event.is_set()
+ terminal_status = 'stopped' if was_stopped else 'completed' if stream_completed_normally else 'failed'
+
agent_run_manager.unregister_agent_run(
- agent_request.conversation_id, user_id)
+ agent_request.conversation_id, user_id, status=terminal_status)
# Mark channel as completed and schedule cleanup
if channel is not None:
- terminal_status = 'completed' if stream_completed_normally else 'failed'
await streaming_channel_manager.complete_channel(
conversation_id=agent_request.conversation_id,
user_id=user_id,
@@ -2747,6 +2790,11 @@ async def generate_stream_with_memory(
preprocess_manager.register_preprocess_task(
task_id, conversation_id, current_task
)
+ cancel_poll_task = (
+ asyncio.create_task(_cancel_task_on_runtime_signal(conversation_id, user_id, current_task))
+ if current_task
+ else None
+ )
# Helper to emit memory_search token
def _memory_token(message_text: str) -> str:
@@ -2845,6 +2893,8 @@ def _memory_token(message_text: str) -> str:
yield _safe_agent_stream_error_chunk()
return
finally:
+ if cancel_poll_task and not cancel_poll_task.done():
+ cancel_poll_task.cancel()
# Always unregister preprocess task
preprocess_manager.unregister_preprocess_task(task_id)
@@ -3035,8 +3085,13 @@ async def run_agent_stream(
user_id=resolved_user_id,
conversation_id=agent_request.conversation_id
)
+ run_state = await runtime_state_service.get_run_state_async(
+ user_id=resolved_user_id,
+ conversation_id=agent_request.conversation_id,
+ )
+ is_remote_running = run_state.get("status") == "running"
- if existing_run_info is None:
+ if existing_run_info is None and not is_remote_running:
# Agent has finished while frontend was disconnected
# Update message status to completed if it's still streaming
try:
@@ -3060,8 +3115,82 @@ async def run_agent_stream(
conversation_id=agent_request.conversation_id,
user_id=resolved_user_id
)
+ last_unit_index = resume_info["resume_from_unit_index"] - 1
+
+ def _resume_status_chunk(replay_chunk_count: int) -> str:
+ payload = {
+ 'status': 'resumed',
+ 'last_unit_index': last_unit_index,
+ 'replay_chunk_count': replay_chunk_count,
+ }
+ return f"data: {json.dumps(payload)}\n\n"
+
+ def _resume_completed_chunk(status: str = "completed") -> str:
+ payload = {
+ 'status': status,
+ 'last_unit_index': last_unit_index,
+ }
+ return f"data: {json.dumps(payload)}\n\n"
if channel is None:
+ if runtime_state_service.enabled and is_remote_running:
+ async def redis_channel_stream():
+ replay_events = await runtime_state_service.read_stream_events_async(
+ user_id=resolved_user_id,
+ conversation_id=agent_request.conversation_id,
+ )
+ replay_chunk_count = len(replay_events)
+
+ yield STREAM_STATUS_EVENT
+ yield _resume_status_chunk(replay_chunk_count)
+
+ last_event_id = "0-0"
+ for event_id, chunk in replay_events:
+ last_event_id = event_id
+ if chunk:
+ yield chunk
+
+ while True:
+ events = await runtime_state_service.wait_for_stream_events_async(
+ user_id=resolved_user_id,
+ conversation_id=agent_request.conversation_id,
+ last_id=last_event_id,
+ )
+ for event_id, chunk in events:
+ last_event_id = event_id
+ if chunk:
+ yield chunk
+
+ stream_status = await runtime_state_service.get_stream_status_async(
+ user_id=resolved_user_id,
+ conversation_id=agent_request.conversation_id,
+ )
+ latest_run_state = await runtime_state_service.get_run_state_async(
+ user_id=resolved_user_id,
+ conversation_id=agent_request.conversation_id,
+ )
+ if stream_status.get("status") or latest_run_state.get("status") in {
+ "completed",
+ "failed",
+ "stopped",
+ }:
+ break
+
+ terminal_status = stream_status.get("status") or latest_run_state.get("status") or "completed"
+ yield STREAM_STATUS_EVENT
+ yield _resume_completed_chunk(terminal_status)
+
+ return StreamingResponse(
+ redis_channel_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Stream-Status": "resumed",
+ "X-Last-Unit-Index": str(resume_info['resume_from_unit_index']),
+ },
+ )
+
# No channel exists, agent might be in a different state
return JSONResponse(
status_code=HTTPStatus.OK,
@@ -3078,7 +3207,7 @@ async def channel_stream():
# Emit status event first with chunk count for skip tracking
yield STREAM_STATUS_EVENT
- yield f'data: {{"status": "resumed", "last_unit_index": {resume_info["resume_from_unit_index"] - 1}, "replay_chunk_count": {replay_chunk_count}}}\n\n'
+ yield _resume_status_chunk(replay_chunk_count)
# Use subscribe_with_history(0) to replay ALL chunks from the buffer
# This ensures no chunks are lost even if frontend disconnected during streaming
@@ -3088,7 +3217,7 @@ async def channel_stream():
# Mark as complete when channel ends
yield STREAM_STATUS_EVENT
- yield f'data: {{"status": "completed", "last_unit_index": {resume_info["resume_from_unit_index"] - 1}}}\n\n'
+ yield _resume_completed_chunk()
return StreamingResponse(
channel_stream(),
@@ -3102,6 +3231,11 @@ async def channel_stream():
)
# Normal mode: start new stream
+ await runtime_state_service.reset_stream_async(
+ user_id=resolved_user_id,
+ conversation_id=agent_request.conversation_id,
+ )
+
if not agent_request.is_debug and not skip_user_save:
save_messages(
agent_request,
diff --git a/backend/services/northbound_service.py b/backend/services/northbound_service.py
index 4589eb4ba..46c60eae8 100644
--- a/backend/services/northbound_service.py
+++ b/backend/services/northbound_service.py
@@ -11,7 +11,12 @@
from fastapi.responses import StreamingResponse
-from consts.const import ASSET_OWNER_TENANT_ID
+from consts.const import (
+ ASSET_OWNER_TENANT_ID,
+ NORTHBOUND_IDEMPOTENCY_TTL_SECONDS,
+ NORTHBOUND_RATE_LIMIT_ENABLED,
+ NORTHBOUND_RATE_LIMIT_PER_MINUTE,
+)
from consts.exceptions import (
LimitExceededError,
UnauthorizedError,
@@ -25,6 +30,7 @@
stop_agent_tasks,
get_agent_id_by_name
)
+from services.runtime_state_service import runtime_state_service
from services.agent_version_service import list_published_agents_impl
from services.conversation_management_service import (
save_conversation_user,
@@ -234,10 +240,8 @@ def _normalize_northbound_attachments(
# In-memory idempotency and rate limit placeholders
# -----------------------------
_IDEMPOTENCY_RUNNING: Dict[str, float] = {}
-_IDEMPOTENCY_TTL_SECONDS_DEFAULT = 10 * 60
_IDEMPOTENCY_LOCK = asyncio.Lock()
-_RATE_LIMIT_PER_MINUTE = 120 # simple default quota per tenant per minute
_RATE_STATE: Dict[str, Dict[str, int]] = {}
_RATE_LOCK = asyncio.Lock()
@@ -252,10 +256,21 @@ def _minute_bucket(ts: Optional[float] = None) -> str:
async def idempotency_start(key: str, ttl_seconds: Optional[int] = None) -> None:
+ ttl = ttl_seconds or NORTHBOUND_IDEMPOTENCY_TTL_SECONDS
+ if runtime_state_service.enabled:
+ try:
+ acquired = await runtime_state_service.acquire_idempotency_async(key, ttl)
+ except Exception:
+ logger.exception("Northbound idempotency Redis operation failed")
+ raise LimitExceededError("Idempotency service is unavailable. Please try again later.")
+ if not acquired:
+ raise LimitExceededError("Duplicate request is still running, please wait.")
+ return
+
async with _IDEMPOTENCY_LOCK:
# purge expired
now = _now_seconds()
- expired = [k for k, v in _IDEMPOTENCY_RUNNING.items() if now - v > (ttl_seconds or _IDEMPOTENCY_TTL_SECONDS_DEFAULT)]
+ expired = [k for k, v in _IDEMPOTENCY_RUNNING.items() if now - v > ttl]
for k in expired:
_IDEMPOTENCY_RUNNING.pop(k, None)
if key in _IDEMPOTENCY_RUNNING:
@@ -264,6 +279,13 @@ async def idempotency_start(key: str, ttl_seconds: Optional[int] = None) -> None
async def idempotency_end(key: str) -> None:
+ if runtime_state_service.enabled:
+ try:
+ await runtime_state_service.release_idempotency_async(key)
+ except Exception as exc:
+ logger.warning("Northbound idempotency release failed: %s", exc)
+ return
+
async with _IDEMPOTENCY_LOCK:
_IDEMPOTENCY_RUNNING.pop(key, None)
@@ -274,11 +296,27 @@ async def _release_idempotency_after_delay(key: str, seconds: int = 3) -> None:
async def check_and_consume_rate_limit(tenant_id: str) -> None:
+ if not NORTHBOUND_RATE_LIMIT_ENABLED:
+ return
+
+ if runtime_state_service.enabled:
+ try:
+ await runtime_state_service.consume_rate_limit_async(
+ tenant_id=tenant_id,
+ limit_per_minute=NORTHBOUND_RATE_LIMIT_PER_MINUTE,
+ )
+ return
+ except ValueError:
+ raise LimitExceededError("Query rate exceeded limit. Please try again later")
+ except Exception:
+ logger.exception("Northbound rate limit Redis operation failed")
+ raise LimitExceededError("Rate limit service is unavailable. Please try again later.")
+
bucket = _minute_bucket()
async with _RATE_LOCK:
state = _RATE_STATE.setdefault(tenant_id, {})
count = state.get(bucket, 0)
- if count >= _RATE_LIMIT_PER_MINUTE:
+ if count >= NORTHBOUND_RATE_LIMIT_PER_MINUTE:
raise LimitExceededError("Query rate exceeded limit. Please try again later")
state[bucket] = count + 1
# cleanup old buckets, keep only current
@@ -371,8 +409,8 @@ async def start_streaming_chat(
except Exception as e:
raise Exception(f"Failed to persist user message: {str(e)}")
- except LimitExceededError as _:
- raise LimitExceededError("Query rate exceeded limit. Please try again later.")
+ except LimitExceededError as exc:
+ raise LimitExceededError(str(exc))
except UnauthorizedError as _:
raise UnauthorizedError("Cannot authenticate.")
except Exception as e:
diff --git a/backend/services/runtime_state_service.py b/backend/services/runtime_state_service.py
new file mode 100644
index 000000000..49fea9744
--- /dev/null
+++ b/backend/services/runtime_state_service.py
@@ -0,0 +1,319 @@
+import asyncio
+import hashlib
+import logging
+import socket
+import time
+from typing import Any, Dict, List, Optional, Tuple
+
+try:
+ import redis
+except ImportError:
+ redis = None
+
+from consts.const import (
+ RUNTIME_CANCEL_TTL_SECONDS,
+ RUNTIME_COMPLETED_TTL_SECONDS,
+ RUNTIME_RUN_TTL_SECONDS,
+ RUNTIME_STATE_REDIS_URL,
+ RUNTIME_STREAM_MAX_LEN,
+ RUNTIME_STREAM_TTL_SECONDS,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class RuntimeStateService:
+ """Redis-backed short-lived state used by multi-replica runtime services."""
+
+ def __init__(self):
+ self._client: Optional[Any] = None
+ self._pod_name = socket.gethostname()
+
+ @property
+ def enabled(self) -> bool:
+ return bool(RUNTIME_STATE_REDIS_URL)
+
+ @property
+ def client(self) -> Any:
+ if not RUNTIME_STATE_REDIS_URL:
+ raise ValueError("RUNTIME_STATE_REDIS_URL or REDIS_URL environment variable is not set")
+ if redis is None:
+ raise ValueError("redis package is not installed")
+ if self._client is None:
+ self._client = redis.from_url(
+ RUNTIME_STATE_REDIS_URL,
+ socket_timeout=5,
+ socket_connect_timeout=5,
+ decode_responses=True,
+ )
+ return self._client
+
+ def _run_key(self, user_id: str, conversation_id: int) -> str:
+ return f"runtime:run:{user_id}:{conversation_id}"
+
+ def _cancel_key(self, user_id: str, conversation_id: int) -> str:
+ return f"runtime:cancel:{user_id}:{conversation_id}"
+
+ def _stream_key(self, user_id: str, conversation_id: int) -> str:
+ return f"runtime:stream:{user_id}:{conversation_id}"
+
+ def _stream_done_key(self, user_id: str, conversation_id: int) -> str:
+ return f"runtime:stream:done:{user_id}:{conversation_id}"
+
+ def _idempotency_key(self, key: str) -> str:
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
+ return f"northbound:idempotency:{digest}"
+
+ def _rate_key(self, tenant_id: str, minute_bucket: str) -> str:
+ return f"northbound:rate:{tenant_id}:{minute_bucket}"
+
+ def _expire_completed_runtime_keys(self, user_id: str, conversation_id: int) -> None:
+ ttl = max(1, RUNTIME_COMPLETED_TTL_SECONDS)
+ for key in (
+ self._run_key(user_id, conversation_id),
+ self._cancel_key(user_id, conversation_id),
+ self._stream_key(user_id, conversation_id),
+ self._stream_done_key(user_id, conversation_id),
+ ):
+ self.client.expire(key, ttl)
+
+ def reset_stream(self, user_id: str, conversation_id: int) -> None:
+ if not self.enabled:
+ return
+ try:
+ self.client.delete(
+ self._stream_key(user_id, conversation_id),
+ self._stream_done_key(user_id, conversation_id),
+ )
+ except Exception as exc:
+ logger.warning("Failed to reset runtime stream state: %s", exc)
+
+ async def reset_stream_async(self, user_id: str, conversation_id: int) -> None:
+ await asyncio.to_thread(self.reset_stream, user_id, conversation_id)
+
+ def register_run(self, user_id: str, conversation_id: int, message_id: Optional[int] = None) -> None:
+ if not self.enabled:
+ return
+ try:
+ now = str(int(time.time()))
+ payload = {
+ "owner_pod": self._pod_name,
+ "status": "running",
+ "started_at": now,
+ "updated_at": now,
+ }
+ if message_id is not None:
+ payload["message_id"] = str(message_id)
+ key = self._run_key(user_id, conversation_id)
+ self.client.hset(key, mapping=payload)
+ self.client.expire(key, RUNTIME_RUN_TTL_SECONDS)
+ self.client.delete(self._cancel_key(user_id, conversation_id))
+ except Exception as exc:
+ logger.warning("Failed to register runtime run state: %s", exc)
+
+ def mark_run_finished(self, user_id: str, conversation_id: int, status: str) -> None:
+ if not self.enabled:
+ return
+ try:
+ key = self._run_key(user_id, conversation_id)
+ self.client.hset(key, mapping={
+ "status": status,
+ "updated_at": str(int(time.time())),
+ })
+ self._expire_completed_runtime_keys(user_id, conversation_id)
+ except Exception as exc:
+ logger.warning("Failed to mark runtime run state as finished: %s", exc)
+
+ def get_run_state(self, user_id: str, conversation_id: int) -> Dict[str, str]:
+ if not self.enabled:
+ return {}
+ try:
+ return self.client.hgetall(self._run_key(user_id, conversation_id)) or {}
+ except Exception as exc:
+ logger.warning("Failed to get runtime run state: %s", exc)
+ return {}
+
+ async def get_run_state_async(self, user_id: str, conversation_id: int) -> Dict[str, str]:
+ return await asyncio.to_thread(self.get_run_state, user_id, conversation_id)
+
+ def set_cancel_signal(self, user_id: str, conversation_id: int) -> bool:
+ if not self.enabled:
+ return False
+ try:
+ self.client.setex(self._cancel_key(user_id, conversation_id), RUNTIME_CANCEL_TTL_SECONDS, self._pod_name)
+ return True
+ except Exception as exc:
+ logger.warning("Failed to set runtime cancel signal: %s", exc)
+ return False
+
+ def is_cancelled(self, user_id: str, conversation_id: int) -> bool:
+ if not self.enabled:
+ return False
+ try:
+ return bool(self.client.get(self._cancel_key(user_id, conversation_id)))
+ except Exception as exc:
+ logger.warning("Failed to read runtime cancel signal: %s", exc)
+ return False
+
+ async def is_cancelled_async(self, user_id: str, conversation_id: int) -> bool:
+ return await asyncio.to_thread(self.is_cancelled, user_id, conversation_id)
+
+ def append_stream_event(self, user_id: str, conversation_id: int, chunk: str) -> Optional[str]:
+ if not self.enabled:
+ return None
+ try:
+ stream_key = self._stream_key(user_id, conversation_id)
+ event_id = self.client.xadd(
+ stream_key,
+ {"chunk": chunk},
+ maxlen=RUNTIME_STREAM_MAX_LEN,
+ approximate=True,
+ )
+ self.client.expire(stream_key, RUNTIME_STREAM_TTL_SECONDS)
+ return event_id
+ except Exception as exc:
+ logger.warning("Failed to append runtime stream event: %s", exc)
+ return None
+
+ async def append_stream_event_async(self, user_id: str, conversation_id: int, chunk: str) -> Optional[str]:
+ return await asyncio.to_thread(self.append_stream_event, user_id, conversation_id, chunk)
+
+ def mark_stream_completed(
+ self,
+ user_id: str,
+ conversation_id: int,
+ status: str,
+ error: Optional[str] = None,
+ ) -> None:
+ if not self.enabled:
+ return
+ try:
+ payload = {
+ "status": status,
+ "updated_at": str(int(time.time())),
+ }
+ if error:
+ payload["error"] = error
+ done_key = self._stream_done_key(user_id, conversation_id)
+ self.client.hset(done_key, mapping=payload)
+ self._expire_completed_runtime_keys(user_id, conversation_id)
+ except Exception as exc:
+ logger.warning("Failed to mark runtime stream completed: %s", exc)
+
+ async def mark_stream_completed_async(
+ self,
+ user_id: str,
+ conversation_id: int,
+ status: str,
+ error: Optional[str] = None,
+ ) -> None:
+ await asyncio.to_thread(self.mark_stream_completed, user_id, conversation_id, status, error)
+
+ def get_stream_status(self, user_id: str, conversation_id: int) -> Dict[str, str]:
+ if not self.enabled:
+ return {}
+ try:
+ return self.client.hgetall(self._stream_done_key(user_id, conversation_id)) or {}
+ except Exception as exc:
+ logger.warning("Failed to get runtime stream status: %s", exc)
+ return {}
+
+ async def get_stream_status_async(self, user_id: str, conversation_id: int) -> Dict[str, str]:
+ return await asyncio.to_thread(self.get_stream_status, user_id, conversation_id)
+
+ def read_stream_events(
+ self,
+ user_id: str,
+ conversation_id: int,
+ after_id: Optional[str] = None,
+ ) -> List[Tuple[str, str]]:
+ if not self.enabled:
+ return []
+ try:
+ min_id = "-" if after_id is None else f"({after_id}"
+ events = self.client.xrange(self._stream_key(user_id, conversation_id), min=min_id)
+ return [(event_id, values.get("chunk", "")) for event_id, values in events]
+ except Exception as exc:
+ logger.warning("Failed to read runtime stream events: %s", exc)
+ return []
+
+ async def read_stream_events_async(
+ self,
+ user_id: str,
+ conversation_id: int,
+ after_id: Optional[str] = None,
+ ) -> List[Tuple[str, str]]:
+ return await asyncio.to_thread(self.read_stream_events, user_id, conversation_id, after_id)
+
+ def wait_for_stream_events(
+ self,
+ user_id: str,
+ conversation_id: int,
+ last_id: str,
+ block_ms: int = 1000,
+ count: int = 100,
+ ) -> List[Tuple[str, str]]:
+ if not self.enabled:
+ return []
+ try:
+ response = self.client.xread(
+ {self._stream_key(user_id, conversation_id): last_id},
+ count=count,
+ block=block_ms,
+ )
+ if not response:
+ return []
+ _, events = response[0]
+ return [(event_id, values.get("chunk", "")) for event_id, values in events]
+ except Exception as exc:
+ logger.warning("Failed to wait for runtime stream events: %s", exc)
+ return []
+
+ async def wait_for_stream_events_async(
+ self,
+ user_id: str,
+ conversation_id: int,
+ last_id: str,
+ block_ms: int = 1000,
+ count: int = 100,
+ ) -> List[Tuple[str, str]]:
+ return await asyncio.to_thread(
+ self.wait_for_stream_events,
+ user_id,
+ conversation_id,
+ last_id,
+ block_ms,
+ count,
+ )
+
+ def acquire_idempotency(self, key: str, ttl_seconds: int) -> bool:
+ redis_key = self._idempotency_key(key)
+ acquired = self.client.set(redis_key, self._pod_name, nx=True, ex=ttl_seconds)
+ return bool(acquired)
+
+ async def acquire_idempotency_async(self, key: str, ttl_seconds: int) -> bool:
+ return await asyncio.to_thread(self.acquire_idempotency, key, ttl_seconds)
+
+ def release_idempotency(self, key: str) -> None:
+ self.client.delete(self._idempotency_key(key))
+
+ async def release_idempotency_async(self, key: str) -> None:
+ await asyncio.to_thread(self.release_idempotency, key)
+
+ def consume_rate_limit(self, tenant_id: str, limit_per_minute: int) -> int:
+ minute_bucket = str(int(time.time() // 60))
+ key = self._rate_key(tenant_id, minute_bucket)
+ pipe = self.client.pipeline()
+ pipe.incr(key)
+ pipe.expire(key, 120)
+ count, _ = pipe.execute()
+ count = int(count)
+ if count > limit_per_minute:
+ raise ValueError("rate limit exceeded")
+ return count
+
+ async def consume_rate_limit_async(self, tenant_id: str, limit_per_minute: int) -> int:
+ return await asyncio.to_thread(self.consume_rate_limit, tenant_id, limit_per_minute)
+
+
+runtime_state_service = RuntimeStateService()
diff --git a/backend/services/streaming_channel.py b/backend/services/streaming_channel.py
index 2f62ee33c..ad3f20ab3 100644
--- a/backend/services/streaming_channel.py
+++ b/backend/services/streaming_channel.py
@@ -10,6 +10,8 @@
import logging
from typing import Dict, Optional, AsyncIterator, List
+from services.runtime_state_service import runtime_state_service
+
logger = logging.getLogger(__name__)
# Default history buffer size (kept for backward compatibility with callers).
@@ -85,6 +87,12 @@ async def publish(self, chunk: str):
async with self._lock:
self._history_buffer.append(chunk)
+ await runtime_state_service.append_stream_event_async(
+ user_id=self.user_id,
+ conversation_id=self.conversation_id,
+ chunk=chunk,
+ )
+
# Wake up waiting subscribers immediately
self._data_event.set()
@@ -276,6 +284,11 @@ async def complete_channel(
channel = self.get_channel(conversation_id, user_id)
if channel:
channel.complete(status)
+ await runtime_state_service.mark_stream_completed_async(
+ user_id=user_id,
+ conversation_id=conversation_id,
+ status=status,
+ )
async def remove_channel(self, conversation_id: int, user_id: str):
"""Remove a channel from the manager."""
diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh
index 933b3e7c6..c36526d14 100755
--- a/deploy/k8s/deploy.sh
+++ b/deploy/k8s/deploy.sh
@@ -100,26 +100,32 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--is-mainland)
IS_MAINLAND="$2"
+ K8S_IS_MAINLAND_EXPLICIT="true"
shift 2
;;
--version)
APP_VERSION="$2"
+ K8S_APP_VERSION_EXPLICIT="true"
shift 2
;;
--deployment-version)
DEPLOYMENT_VERSION="$2"
+ K8S_DEPLOYMENT_VERSION_EXPLICIT="true"
shift 2
;;
--persistence-mode)
PERSISTENCE_MODE="$2"
+ K8S_PERSISTENCE_MODE_EXPLICIT="true"
shift 2
;;
--storage-class|--storageclass|--storage-class-name|--sc)
STORAGE_CLASS_NAME="$2"
+ K8S_STORAGE_CLASS_NAME_EXPLICIT="true"
shift 2
;;
--local-path)
LOCAL_PATH="$2"
+ K8S_LOCAL_PATH_EXPLICIT="true"
shift 2
;;
--local-node-name)
@@ -128,6 +134,7 @@ while [[ $# -gt 0 ]]; do
;;
--existing-claim-prefix)
EXISTING_CLAIM_PREFIX="$2"
+ K8S_EXISTING_CLAIM_PREFIX_EXPLICIT="true"
shift 2
;;
--wait-timeout)
@@ -144,7 +151,9 @@ while [[ $# -gt 0 ]]; do
done
cd "$SCRIPT_DIR"
-deployment_source_root_env "$PROJECT_ROOT" "$PROJECT_ROOT/docker" || exit 1
+if [ "$COMMAND" != "help" ]; then
+ deployment_source_root_env "$PROJECT_ROOT" "$PROJECT_ROOT/docker" || exit 1
+fi
# Helper function to sanitize input (remove Windows CR)
sanitize_input() {
@@ -153,6 +162,8 @@ sanitize_input() {
}
apply_deployment_common_config() {
+ load_deploy_options
+
if [ -z "$APP_VERSION" ]; then
APP_VERSION=$(get_app_version)
fi
@@ -213,7 +224,7 @@ render_one_persistence_values() {
printf ' mode: "%s"\n' "$PERSISTENCE_MODE"
printf ' storageClassName: "%s"\n' "$storage_class"
printf ' accessModes:\n'
- printf ' - ReadWriteOnce\n'
+ printf ' - ReadWriteMany\n'
printf ' localPath: "%s/%s"\n' "$LOCAL_PATH" "$component"
printf ' existingClaim: "%s"\n' "$(persistence_existing_claim "$component")"
printf ' storage:\n'
@@ -234,7 +245,7 @@ render_monitoring_persistence_values() {
printf ' mode: "%s"\n' "$PERSISTENCE_MODE"
printf ' storageClassName: "%s"\n' "$storage_class"
printf ' accessModes:\n'
- printf ' - ReadWriteOnce\n'
+ printf ' - ReadWriteMany\n'
printf ' localPath: "%s"\n' "$LOCAL_PATH"
printf ' existingClaimPrefix: "%s"\n' "$EXISTING_CLAIM_PREFIX"
} >> "$output_file"
@@ -252,7 +263,7 @@ render_shared_storage_persistence_values() {
printf ' mode: "%s"\n' "$PERSISTENCE_MODE"
printf ' storageClassName: "%s"\n' "$storage_class"
printf ' accessModes:\n'
- printf ' - ReadWriteOnce\n'
+ printf ' - ReadWriteMany\n'
printf ' workspace:\n'
printf ' size: "10Gi"\n'
printf ' localPath: "/var/lib/nexent"\n'
@@ -541,21 +552,92 @@ get_app_version() {
# Persist deployment options to file
persist_deploy_options() {
+ deployment_persist_local_config "$DEPLOY_OPTIONS_FILE"
{
- echo "APP_VERSION=\"${APP_VERSION}\""
- echo "IS_MAINLAND=\"${IS_MAINLAND_SAVED}\""
- echo "DEPLOYMENT_VERSION=\"${VERSION_CHOICE_SAVED}\""
- echo "PERSISTENCE_MODE=\"${PERSISTENCE_MODE}\""
- echo "STORAGE_CLASS_NAME=\"${STORAGE_CLASS_NAME}\""
- echo "LOCAL_PATH=\"${LOCAL_PATH}\""
- echo "EXISTING_CLAIM_PREFIX=\"${EXISTING_CLAIM_PREFIX}\""
- } > "$DEPLOY_OPTIONS_FILE"
+ printf 'k8s:\n'
+ printf ' appVersion: %s\n' "$(yaml_quote "$APP_VERSION")"
+ printf ' isMainland: %s\n' "$(yaml_quote "$IS_MAINLAND_SAVED")"
+ printf ' deploymentVersion: %s\n' "$(yaml_quote "$VERSION_CHOICE_SAVED")"
+ printf ' persistenceMode: %s\n' "$(yaml_quote "$PERSISTENCE_MODE")"
+ printf ' storageClassName: %s\n' "$(yaml_quote "$STORAGE_CLASS_NAME")"
+ printf ' localPath: %s\n' "$(yaml_quote "$LOCAL_PATH")"
+ printf ' existingClaimPrefix: %s\n' "$(yaml_quote "$EXISTING_CLAIM_PREFIX")"
+ } >> "$DEPLOY_OPTIONS_FILE"
+}
+
+deploy_options_unquote() {
+ local value
+ value="$(deployment_trim "$1")"
+ value="${value%$'\r'}"
+ value="${value%%#*}"
+ value="$(deployment_trim "$value")"
+ value="${value%\"}"
+ value="${value#\"}"
+ value="${value%\'}"
+ value="${value#\'}"
+ printf '%s' "$value"
+}
+
+apply_loaded_deploy_option() {
+ local key="$1"
+ local value="$2"
+ case "$key" in
+ APP_VERSION|appVersion)
+ [ -n "${K8S_APP_VERSION_EXPLICIT:-}" ] || APP_VERSION="$value"
+ ;;
+ IS_MAINLAND|isMainland)
+ [ -n "${K8S_IS_MAINLAND_EXPLICIT:-}" ] || IS_MAINLAND="$value"
+ ;;
+ DEPLOYMENT_VERSION|deploymentVersion)
+ [ -n "${K8S_DEPLOYMENT_VERSION_EXPLICIT:-}" ] || DEPLOYMENT_VERSION="$value"
+ ;;
+ PERSISTENCE_MODE|persistenceMode)
+ [ -n "${K8S_PERSISTENCE_MODE_EXPLICIT:-}" ] || PERSISTENCE_MODE="$value"
+ ;;
+ STORAGE_CLASS_NAME|storageClassName)
+ [ -n "${K8S_STORAGE_CLASS_NAME_EXPLICIT:-}" ] || STORAGE_CLASS_NAME="$value"
+ ;;
+ LOCAL_PATH|localPath)
+ [ -n "${K8S_LOCAL_PATH_EXPLICIT:-}" ] || LOCAL_PATH="$value"
+ ;;
+ EXISTING_CLAIM_PREFIX|existingClaimPrefix)
+ [ -n "${K8S_EXISTING_CLAIM_PREFIX_EXPLICIT:-}" ] || EXISTING_CLAIM_PREFIX="$value"
+ ;;
+ esac
}
# Load deployment options from file if exists
load_deploy_options() {
+ local line trimmed key value in_k8s_section
if [ -f "$DEPLOY_OPTIONS_FILE" ]; then
- source "$DEPLOY_OPTIONS_FILE"
+ in_k8s_section="false"
+ while IFS= read -r line || [ -n "$line" ]; do
+ trimmed="$(deployment_trim "${line%%#*}")"
+ [ -z "$trimmed" ] && continue
+
+ if [[ "$trimmed" =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
+ key="${BASH_REMATCH[1]}"
+ value="$(deploy_options_unquote "${BASH_REMATCH[2]}")"
+ apply_loaded_deploy_option "$key" "$value"
+ continue
+ fi
+
+ if [[ "$trimmed" =~ ^k8s:[[:space:]]*$ ]]; then
+ in_k8s_section="true"
+ continue
+ fi
+
+ if [[ "$line" =~ ^[A-Za-z][A-Za-z0-9_]*:[[:space:]]* ]]; then
+ in_k8s_section="false"
+ continue
+ fi
+
+ if [ "$in_k8s_section" = "true" ] && [[ "$line" =~ ^[[:space:]]+([A-Za-z][A-Za-z0-9_]*):[[:space:]]*(.*)$ ]]; then
+ key="${BASH_REMATCH[1]}"
+ value="$(deploy_options_unquote "${BASH_REMATCH[2]}")"
+ apply_loaded_deploy_option "$key" "$value"
+ fi
+ done < "$DEPLOY_OPTIONS_FILE"
fi
}
@@ -934,7 +1016,7 @@ apply() {
# Step 1: Select deployment components, port policy and image source.
apply_deployment_common_config
- deployment_persist_local_config
+ persist_deploy_options
# Step 2: Render generated values with image tags from selected environment
update_values_yaml
@@ -1205,7 +1287,6 @@ apply() {
# Save deployment options for future use
persist_deploy_options
- deployment_persist_local_config
# Step 11: Pull MCP image after persisting deployment options
pull_mcp_image
diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml
index 0f1a4a5a3..aa3ed7831 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml
@@ -25,7 +25,7 @@ data:
MCP_MANAGEMENT_API: {{ .Values.config.services.mcpManagementServer | quote }}
DATA_PROCESS_SERVICE: {{ .Values.config.services.dataProcessService | quote }}
NORTHBOUND_API_SERVER: {{ .Values.config.services.northboundServer | quote }}
-
+
# Service URLs (external)
NORTHBOUND_EXTERNAL_URL: {{ .Values.config.services.northboundExternalUrl | quote }}
@@ -43,6 +43,16 @@ data:
# Redis Config
REDIS_URL: {{ .Values.config.redis.url | quote }}
REDIS_BACKEND_URL: {{ .Values.config.redis.backendUrl | quote }}
+ RUNTIME_STATE_REDIS_URL: {{ default .Values.config.redis.url .Values.config.runtimeState.redisUrl | quote }}
+ RUNTIME_STREAM_TTL_SECONDS: {{ .Values.config.runtimeState.streamTtlSeconds | quote }}
+ RUNTIME_STREAM_MAX_LEN: {{ .Values.config.runtimeState.streamMaxLen | quote }}
+ RUNTIME_RUN_TTL_SECONDS: {{ .Values.config.runtimeState.runTtlSeconds | quote }}
+ RUNTIME_CANCEL_TTL_SECONDS: {{ .Values.config.runtimeState.cancelTtlSeconds | quote }}
+ RUNTIME_COMPLETED_TTL_SECONDS: {{ .Values.config.runtimeState.completedTtlSeconds | quote }}
+ RUNTIME_CANCEL_POLL_INTERVAL_SECONDS: {{ .Values.config.runtimeState.cancelPollIntervalSeconds | quote }}
+ NORTHBOUND_IDEMPOTENCY_TTL_SECONDS: {{ .Values.config.northbound.idempotencyTtlSeconds | quote }}
+ NORTHBOUND_RATE_LIMIT_ENABLED: {{ .Values.config.northbound.rateLimitEnabled | quote }}
+ NORTHBOUND_RATE_LIMIT_PER_MINUTE: {{ .Values.config.northbound.rateLimitPerMinute | quote }}
# Model Engine Config
MODEL_ENGINE_ENABLED: {{ .Values.config.modelEngine.enabled | quote }}
diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/shared-storage.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/shared-storage.yaml
index 560dd8b45..a54358e1f 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-common/templates/shared-storage.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/shared-storage.yaml
@@ -2,7 +2,7 @@
{{- $shared := default dict $global.sharedStorage }}
{{- $mode := default "local" $shared.mode }}
{{- $storageClassName := default "" $shared.storageClassName }}
-{{- $accessModes := default (list "ReadWriteOnce") $shared.accessModes }}
+{{- $accessModes := default (list "ReadWriteMany") $shared.accessModes }}
{{- $workspace := default dict $shared.workspace }}
{{- $workspaceSize := default "10Gi" $workspace.size }}
{{- $workspaceLocalPath := default "/var/lib/nexent" $workspace.localPath }}
diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml
index 26bdafc22..bdf5f1398 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml
@@ -38,6 +38,18 @@ config:
url: "redis://nexent-redis:6379/0"
backendUrl: "redis://nexent-redis:6379/1"
port: "6379"
+ runtimeState:
+ redisUrl: ""
+ streamTtlSeconds: "86400"
+ streamMaxLen: "10000"
+ runTtlSeconds: "86400"
+ cancelTtlSeconds: "86400"
+ completedTtlSeconds: "300"
+ cancelPollIntervalSeconds: "1.0"
+ northbound:
+ idempotencyTtlSeconds: "600"
+ rateLimitEnabled: "true"
+ rateLimitPerMinute: "120"
minio:
endpoint: "http://nexent-minio:9000"
region: "cn-north-1"
diff --git a/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml
index 1bbda5efa..a393ce86e 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml
@@ -14,6 +14,8 @@ metadata:
"helm.sh/hook-weight": "20"
spec:
replicas: {{ .Values.replicaCount }}
+ strategy:
+{{ toYaml .Values.strategy | indent 4 }}
selector:
matchLabels:
app: nexent-config
diff --git a/deploy/k8s/helm/nexent/charts/nexent-config/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-config/values.yaml
index 90ea85e8c..b98d139f1 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-config/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-config/values.yaml
@@ -1,5 +1,11 @@
replicaCount: 1
+strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+
images:
backend:
repository: nexent/nexent
diff --git a/deploy/k8s/helm/nexent/charts/nexent-elasticsearch/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-elasticsearch/values.yaml
index 620f7f7ad..99b057980 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-elasticsearch/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-elasticsearch/values.yaml
@@ -20,7 +20,7 @@ persistence:
mode: local
storageClassName: nexent-local
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
localPath: "/var/lib/nexent-data/nexent-elasticsearch"
existingClaim: ""
diff --git a/deploy/k8s/helm/nexent/charts/nexent-minio/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-minio/values.yaml
index a8ee99381..69700debe 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-minio/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-minio/values.yaml
@@ -20,7 +20,7 @@ persistence:
mode: local
storageClassName: nexent-local
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
localPath: "/var/lib/nexent-data/nexent-minio"
existingClaim: ""
diff --git a/deploy/k8s/helm/nexent/charts/nexent-monitoring/templates/_helpers.tpl b/deploy/k8s/helm/nexent/charts/nexent-monitoring/templates/_helpers.tpl
index dd7c0fa26..bd109c006 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-monitoring/templates/_helpers.tpl
+++ b/deploy/k8s/helm/nexent/charts/nexent-monitoring/templates/_helpers.tpl
@@ -54,7 +54,7 @@
{{- $mode := default "local" $root.Values.persistence.mode -}}
{{- $storageClassName := default "" $root.Values.persistence.storageClassName -}}
{{- $localPath := default "/var/lib/nexent-data" $root.Values.persistence.localPath -}}
-{{- $accessModes := default (list "ReadWriteOnce") $root.Values.persistence.accessModes -}}
+{{- $accessModes := default (list "ReadWriteMany") $root.Values.persistence.accessModes -}}
{{- if and $root.Values.enabled $root.Values.persistence.enabled -}}
{{- if eq $mode "local" }}
apiVersion: v1
diff --git a/deploy/k8s/helm/nexent/charts/nexent-monitoring/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-monitoring/values.yaml
index 0066980ad..4ef831ef7 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-monitoring/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-monitoring/values.yaml
@@ -163,6 +163,6 @@ persistence:
mode: local
storageClassName: nexent-local
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
localPath: /var/lib/nexent-data
existingClaimPrefix: ""
diff --git a/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml
index 37a1372a8..0ca8ebb51 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml
@@ -14,6 +14,8 @@ metadata:
"helm.sh/hook-weight": "20"
spec:
replicas: {{ .Values.replicaCount }}
+ strategy:
+{{ toYaml .Values.strategy | indent 4 }}
selector:
matchLabels:
app: nexent-northbound
diff --git a/deploy/k8s/helm/nexent/charts/nexent-northbound/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-northbound/values.yaml
index 600728432..c384cc72e 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-northbound/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-northbound/values.yaml
@@ -1,5 +1,11 @@
replicaCount: 1
+strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+
images:
backend:
repository: nexent/nexent
diff --git a/deploy/k8s/helm/nexent/charts/nexent-postgresql/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-postgresql/values.yaml
index eeb6b2e38..694fad1e5 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-postgresql/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-postgresql/values.yaml
@@ -20,7 +20,7 @@ persistence:
mode: local
storageClassName: nexent-local
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
localPath: "/var/lib/nexent-data/nexent-postgresql"
existingClaim: ""
diff --git a/deploy/k8s/helm/nexent/charts/nexent-redis/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-redis/values.yaml
index 3c94070b4..3b7ceadfa 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-redis/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-redis/values.yaml
@@ -20,6 +20,6 @@ persistence:
mode: local
storageClassName: nexent-local
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
localPath: "/var/lib/nexent-data/nexent-redis"
existingClaim: ""
diff --git a/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml
index 2317d5e51..0d29634f5 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml
@@ -14,6 +14,8 @@ metadata:
"helm.sh/hook-weight": "20"
spec:
replicas: {{ .Values.replicaCount }}
+ strategy:
+{{ toYaml .Values.strategy | indent 4 }}
selector:
matchLabels:
app: nexent-runtime
diff --git a/deploy/k8s/helm/nexent/charts/nexent-runtime/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-runtime/values.yaml
index b593d3e66..c713ce15c 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-runtime/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-runtime/values.yaml
@@ -1,5 +1,11 @@
replicaCount: 1
+strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+
images:
backend:
repository: nexent/nexent
diff --git a/deploy/k8s/helm/nexent/charts/nexent-supabase-db/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-supabase-db/values.yaml
index fc61e6c93..c5a156683 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-supabase-db/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-supabase-db/values.yaml
@@ -20,7 +20,7 @@ persistence:
mode: local
storageClassName: nexent-local
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
localPath: "/var/lib/nexent-data/nexent-supabase-db"
existingClaim: ""
diff --git a/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml
index ee636fe6d..af07956e5 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml
@@ -9,6 +9,8 @@ metadata:
"helm.sh/hook-weight": "20"
spec:
replicas: {{ .Values.replicaCount }}
+ strategy:
+{{ toYaml .Values.strategy | indent 4 }}
selector:
matchLabels:
app: nexent-web
@@ -42,6 +44,12 @@ spec:
value: {{ .Values.global.deploymentVersion | quote }}
- name: MARKET_BACKEND
value: {{ .Values.config.marketBackend | quote }}
+ - name: PROXY_TIMEOUT_MS
+ value: {{ .Values.config.proxyTimeoutMs | quote }}
+ - name: PROXY_WS_TIMEOUT_MS
+ value: {{ .Values.config.proxyWsTimeoutMs | quote }}
+ - name: SSE_PROXY_TIMEOUT_MS
+ value: {{ .Values.config.sseProxyTimeoutMs | quote }}
- name: MODEL_ENGINE_ENABLED
value: {{ .Values.config.modelEngine.enabled | quote }}
resources:
diff --git a/deploy/k8s/helm/nexent/charts/nexent-web/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-web/values.yaml
index 74337791c..acffefb84 100644
--- a/deploy/k8s/helm/nexent/charts/nexent-web/values.yaml
+++ b/deploy/k8s/helm/nexent/charts/nexent-web/values.yaml
@@ -1,5 +1,11 @@
replicaCount: 1
+strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+
images:
web:
repository: nexent/nexent-web
@@ -17,6 +23,9 @@ resources:
config:
marketBackend: "http://60.204.251.153:8010"
+ proxyTimeoutMs: "600000"
+ proxyWsTimeoutMs: "600000"
+ sseProxyTimeoutMs: "600000"
modelEngine:
enabled: "false"
diff --git a/deploy/k8s/helm/nexent/values.yaml b/deploy/k8s/helm/nexent/values.yaml
index bda678f7b..c454daea4 100644
--- a/deploy/k8s/helm/nexent/values.yaml
+++ b/deploy/k8s/helm/nexent/values.yaml
@@ -6,7 +6,7 @@ global:
mode: "local"
storageClassName: "nexent-local"
accessModes:
- - ReadWriteOnce
+ - ReadWriteMany
workspace:
size: "10Gi"
localPath: "/var/lib/nexent"
@@ -90,14 +90,18 @@ nexent-minio:
enabled: true
nexent-config:
enabled: true
+ replicaCount: 1
nexent-runtime:
enabled: true
+ replicaCount: 1
nexent-mcp:
enabled: true
nexent-northbound:
enabled: true
+ replicaCount: 1
nexent-web:
enabled: true
+ replicaCount: 1
nexent-data-process:
enabled: true
nexent-supabase-kong:
diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh
index 6798de5bf..90dd0d53c 100755
--- a/deploy/tests/test_common.sh
+++ b/deploy/tests/test_common.sh
@@ -677,11 +677,20 @@ if grep -q 'appVersion' "$LOCAL_CONFIG"; then
fi
assert_contains "$(cat "$LOCAL_CONFIG")" 'imageRegistryPrefix: "registry.local/nexent"' "persisted local config should include image registry prefix"
+DEPLOY_OPTIONS_FILE="$TMP_DIR/deploy.options"
+deployment_init_defaults
+assert_eq "$TMP_DIR/deploy.options" "$DEPLOYMENT_LOCAL_CONFIG_PATH" "default local config should use deploy.options"
+unset DEPLOY_OPTIONS_FILE
+
K8S_DEPLOY_OPTIONS_BLOCK="$(awk '/persist_deploy_options\(\) {/,/^}/' "$SCRIPT_DIR/../k8s/deploy.sh")"
-assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "PERSISTENCE_MODE=\"${PERSISTENCE_MODE}\""' "k8s deploy options should persist persistence mode"
-assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "STORAGE_CLASS_NAME=\"${STORAGE_CLASS_NAME}\""' "k8s deploy options should persist storage class"
-assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "LOCAL_PATH=\"${LOCAL_PATH}\""' "k8s deploy options should persist local path"
-assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'echo "EXISTING_CLAIM_PREFIX=\"${EXISTING_CLAIM_PREFIX}\""' "k8s deploy options should persist existing claim prefix"
+K8S_DEPLOY_HEADER="$(sed -n '1,60p' "$SCRIPT_DIR/../k8s/deploy.sh")"
+assert_contains "$K8S_DEPLOY_HEADER" 'DEPLOY_OPTIONS_FILE="$SCRIPT_DIR/deploy.options"' "k8s deploy config should use deploy.options"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'deployment_persist_local_config "$DEPLOY_OPTIONS_FILE"' "k8s deploy options should include shared local config"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" "printf 'k8s:\\n'" "k8s deploy options should include a k8s section"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" "persistenceMode:" "k8s deploy options should persist persistence mode"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" "storageClassName:" "k8s deploy options should persist storage class"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" "localPath:" "k8s deploy options should persist local path"
+assert_contains "$K8S_DEPLOY_OPTIONS_BLOCK" "existingClaimPrefix:" "k8s deploy options should persist existing claim prefix"
assert_not_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'LOCAL_NODE_NAME=' "k8s deploy options should not persist deprecated local node name"
assert_not_contains "$K8S_DEPLOY_OPTIONS_BLOCK" 'K8S_WAIT_TIMEOUT_SECONDS=' "k8s deploy options should not persist one-time wait timeout"
if echo "$K8S_DEPLOY_OPTIONS_BLOCK" | grep -Eq 'PASSWORD|TOKEN|JWT|SECRET|KEY'; then
diff --git a/doc/docs/zh/deployment/kubernetes-multi-replica-design.md b/doc/docs/zh/deployment/kubernetes-multi-replica-design.md
new file mode 100644
index 000000000..de49c1287
--- /dev/null
+++ b/doc/docs/zh/deployment/kubernetes-multi-replica-design.md
@@ -0,0 +1,1029 @@
+# Kubernetes 多副本改造设计
+
+## 1. 背景与目标
+
+当前 Kubernetes Helm 部署可以把大部分服务的 `replicaCount` 调大,但项目并不等同于无状态服务。应用层存在进程内运行状态、流式连接状态、后台调度状态和动态 MCP 注册状态;基础设施层也使用单实例 PVC。直接扩容会出现流式恢复失败、停止任务失效、MCP 工具列表不一致、后台任务重复执行、PVC 无法跨节点挂载等问题。
+
+本设计文档的目标是定义“业务应用层多副本”的分阶段改造方案。第一阶段落地四个应用服务的手工多副本:
+
+- `nexent-web`
+- `nexent-config`
+- `nexent-runtime`
+- `nexent-northbound`
+
+第一阶段明确不改 `nexent-mcp`、`nexent-data-process` 的多副本能力;这些组件继续按单副本运行,后续阶段再单独设计。
+
+第一阶段不要求内置 Postgres、Redis、Elasticsearch、MinIO、Supabase DB 做集群高可用。这些组件仍保持单实例,或由生产环境替换为外部托管服务。
+
+第一阶段的“扩缩容”仅指通过 Helm `replicaCount` 手工调整副本数,不启用 autoscaling/HPA。
+
+## 2. 当前状态问题清单
+
+### 2.1 Helm 与存储
+
+当前默认值:
+
+- `deploy/k8s/helm/nexent/values.yaml`
+ - `global.sharedStorage.mode: local`
+ - `global.sharedStorage.accessModes: ReadWriteOnce`
+ - `workspace.localPath: /var/lib/nexent`
+ - `skills.localPath: /var/lib/nexent-data/skills`
+- `deploy/k8s/helm/nexent/charts/nexent-common/templates/shared-storage.yaml`
+ - local 模式会创建 `hostPath` PV。
+
+业务服务挂载共享卷:
+
+- `nexent-config`: `/mnt/nexent`, `/mnt/nexent-data/skills`
+- `nexent-runtime`: `/mnt/nexent`, `/mnt/nexent-data/skills`
+- `nexent-northbound`: `/mnt/nexent`, `/mnt/nexent-data/skills`
+- `nexent-mcp`: `/mnt/nexent`
+- `nexent-data-process`: `/mnt/nexent`
+
+问题:
+
+- `ReadWriteOnce` 通常不能跨节点被多个 Pod 同时读写。
+- `hostPath` 强绑定节点,Pod 调度到其他节点后看不到相同数据。
+- 技能目录和工作区被多个业务服务读写,不能按普通临时目录处理。
+
+### 2.2 Runtime 流式对话状态
+
+相关代码:
+
+- `backend/services/streaming_channel.py`
+- `backend/agents/agent_run_manager.py`
+- `backend/agents/preprocess_manager.py`
+- `backend/services/agent_service.py`
+
+当前状态:
+
+- `StreamingChannelManager` 是进程内 singleton,`_channels` 保存 SSE 历史、订阅者和完成状态。
+- `AgentRunManager` 是进程内 singleton,保存 `agent_runs`、`stop_event`、conversation 级 `ContextManager`。
+- `PreprocessManager` 是进程内 singleton,保存预处理任务和取消句柄。
+
+问题:
+
+- 用户发起 `/api/agent/run` 后,如果重连请求落到另一个 `nexent-runtime` Pod,另一个 Pod 找不到原来的 channel。
+- `/api/agent/stop/{conversation_id}` 落到非 owner Pod 时,找不到原来的 `stop_event`。
+- 正在运行的 agent 状态只存在本地内存,Pod 重启后无法恢复运行控制。
+- conversation 级 `ContextManager` 如果参与正确性,跨 Pod 会产生上下文不一致;如果只是优化,可以允许 miss 后重建。
+
+### 2.3 MCP 动态工具状态
+
+相关代码:
+
+- `backend/mcp_service.py`
+- `backend/services/tool_configuration_service.py`
+- `backend/apps/tool_config_app.py`
+
+当前状态:
+
+- `mcp_service.py` 中 `_openapi_mcp_services` 是进程内 dict。
+- `refresh_openapi_services_by_tenant` 会清理并重新 mount 当前进程中的 FastMCP 服务。
+- config 服务通过 `MCP_MANAGEMENT_API` 调用 `nexent-mcp:5015` 刷新 MCP 服务。
+
+问题:
+
+- 多个 `nexent-mcp` Pod 后,Service 只会把刷新请求转发到其中一个 Pod。
+- 被刷新 Pod 的工具列表正确,其他 Pod 仍是旧状态。
+- FastMCP mount 状态无法仅靠 DB 查询自动保持一致。
+
+### 2.4 Data Process 与后台调度
+
+相关代码:
+
+- `backend/data_process_service.py`
+- `backend/data_process/app.py`
+- `backend/data_process/worker.py`
+- `backend/services/auto_summary_scheduler.py`
+
+当前状态:
+
+- `data_process_service.py` 在一个 Pod 中启动 Redis 连接检查、Ray、Celery workers、Flower 和 FastAPI。
+- `service_processes` 保存本地启动的 worker、Flower、Ray 状态。
+- `auto_summary_scheduler` 在进程内启动线程,`_in_flight` 只做单进程去重。
+
+问题:
+
+- 多个 data-process Pod 会各自启动 Ray 和 worker,需要重新设计队列消费和 Ray 拓扑。
+- auto-summary 在多 Pod 下会重复扫描并处理相同知识库。
+- Flower 和 Ray dashboard 不适合作为每个副本都暴露的应用端口。
+
+### 2.5 Northbound 幂等与限流
+
+相关代码:
+
+- `backend/services/northbound_service.py`
+
+当前状态:
+
+- `_IDEMPOTENCY_RUNNING` 是进程内 dict。
+- `_RATE_STATE` 是进程内 dict。
+
+问题:
+
+- 同一 `Idempotency-Key` 的并发请求打到不同 Pod 时会同时执行。
+- 租户级每分钟限流在每个 Pod 单独计数,实际总额度会随副本数线性放大。
+
+### 2.6 基础设施组件
+
+当前 Helm chart 中以下组件是带 PVC 的单实例 Deployment:
+
+- `nexent-postgresql`
+- `nexent-redis`
+- `nexent-elasticsearch`
+- `nexent-minio`
+- `nexent-supabase-db`
+
+问题:
+
+- Elasticsearch 使用 `discovery.type=single-node`,不能通过 `replicaCount` 变成集群。
+- Postgres、Redis、MinIO、Supabase DB 没有 StatefulSet、主从、Sentinel、Operator 或分布式部署配置。
+- 这些组件第一阶段必须明确为单实例状态依赖,不参与横向扩容。
+
+## 3. 目标架构
+
+### 3.1 状态分层
+
+改造后状态分为四类:
+
+| 状态类型 | 例子 | 第一阶段承载位置 |
+| --- | --- | --- |
+| 持久业务状态 | 用户、租户、智能体、消息、MCP 配置 | Postgres |
+| 对象与文件状态 | 上传文件、预览缓存、技能 zip 或产物 | MinIO 或 RWX PVC |
+| 短生命周期运行状态 | SSE 事件、运行任务 owner、取消信号、幂等键、限流计数 | Redis |
+| 本地优化缓存 | HTTP client、模型实例、可重建 ContextManager | Pod 内存 |
+
+原则:
+
+- 影响正确性的运行状态必须外部化。
+- 本地内存只能保存可丢弃、可重建、不会影响跨 Pod 语义的缓存。
+- 第一阶段 `web`、`config`、`runtime`、`northbound` 不能依赖 sticky session 才正确;未来其它应用服务扩容时也遵循同一原则。
+- Sticky session 可以保留为临时兼容策略,但不是设计前提。
+
+### 3.2 应用层多副本边界
+
+第一阶段允许多副本:
+
+- `nexent-web`
+- `nexent-config`
+- `nexent-runtime`
+- `nexent-northbound`
+
+第一阶段保持单副本:
+
+- `nexent-mcp`
+- `nexent-data-process`
+- `nexent-postgresql`
+- `nexent-redis`
+- `nexent-elasticsearch`
+- `nexent-minio`
+- `nexent-supabase-db`
+- `nexent-openssh`
+
+`nexent-web` 和 `nexent-config` 纳入第一阶段,但只做无状态化确认、启动/迁移并发保护、代理超时和 Helm 手工多副本配置。`nexent-mcp` 的动态工具一致性、`nexent-data-process` 的 scheduler/worker/Ray 拆分均放到后续阶段。
+
+## 4. 详细修改点
+
+### 4.1 Helm values 与模板
+
+#### 4.1.1 新增应用服务手工多副本字段
+
+第一阶段不启用 autoscaling/HPA,也不引入 PDB、topology spread、affinity、nodeSelector、tolerations 等调度增强项。`web`、`config`、`runtime` 和 `northbound` chart 只保留最小手工多副本配置:
+
+- `replicaCount`
+- `strategy`
+
+涉及 chart:
+
+- `deploy/k8s/helm/nexent/charts/nexent-web`
+- `deploy/k8s/helm/nexent/charts/nexent-config`
+- `deploy/k8s/helm/nexent/charts/nexent-runtime`
+- `deploy/k8s/helm/nexent/charts/nexent-northbound`
+
+修改模板:
+
+- `templates/deployment.yaml`
+
+最小配置示例:
+
+```yaml
+replicaCount: 2
+
+strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+```
+
+验收要求:
+
+- 默认仍然 `replicaCount: 1`,避免破坏现有安装。
+- 生产示例 values 中仅 `nexent-web`、`nexent-config`、`nexent-runtime` 和 `nexent-northbound` 配置 `replicaCount: 2`。
+- `web`、`config`、`runtime` 和 `northbound` Deployment 渲染出上述 RollingUpdate 策略。
+- 第一阶段不渲染 `HorizontalPodAutoscaler`,也不新增 `autoscaling.*` values;自动扩缩容作为二期能力单独设计。
+- 第一阶段不新增 PDB、topology spread、affinity、nodeSelector、tolerations;这些调度增强项如有需要放到后续生产增强阶段。
+
+#### 4.1.2 共享存储生产模式
+
+确认并补充 `global.sharedStorage.mode` 的生产使用说明:
+
+- `local`: 当前默认,单节点/开发使用,保留 `hostPath`。
+- `dynamic`: 使用 StorageClass 动态创建 PVC。
+- `existing`: 使用用户提供的 PVC。
+
+第一阶段要求:
+
+- `config`、`runtime` 和 `northbound` 多副本生产示例必须使用 `existing` 或 `dynamic`。
+- 如果 `config`、`runtime` 或 `northbound` 副本数大于 1 且共享 PVC access mode 不是 `ReadWriteMany`,安装文档必须给出明显 warning;Helm 模板阻断可作为后续增强。
+- `local + ReadWriteOnce` 文档标注为不支持跨节点多副本;如果只在单节点开发环境验证,可以保留当前默认值。
+
+需要修改:
+
+- `deploy/k8s/helm/nexent/values.yaml`
+- `deploy/k8s/helm/nexent/README.md`
+- `doc/docs/zh/quick-start/kubernetes-installation.md`
+
+#### 4.1.3 内置状态组件防误扩
+
+第一阶段不改造以下状态组件的多副本能力:
+
+- `nexent-postgresql`
+- `nexent-redis`
+- `nexent-elasticsearch`
+- `nexent-minio`
+- `nexent-supabase-db`
+
+要求:
+
+- values 和安装文档中标注这些组件不应通过 `replicaCount > 1` 扩容。
+- 第一阶段不新增 StatefulSet、Sentinel、Operator 或集群模板。
+- 生产 HA 推荐外接托管服务或后续阶段专门改造。
+
+### 4.2 Runtime 分布式运行状态
+
+#### 4.2.1 新增 Redis 运行状态服务
+
+新增模块:
+
+- `backend/services/runtime_state_service.py`
+
+职责:
+
+- 管理 agent run owner。
+- 管理取消信号。
+- 管理 SSE event buffer。
+- 管理 channel completion 状态。
+- 提供统一 TTL 清理。
+
+建议 Redis key:
+
+| Key | 类型 | 用途 | TTL |
+| --- | --- | --- | --- |
+| `runtime:run:{user_id}:{conversation_id}` | hash | run owner、status、message_id、started_at、updated_at | 运行中 24h;结束后 5min |
+| `runtime:cancel:{user_id}:{conversation_id}` | string | stop 请求设置的取消信号 | 运行中 24h;结束后 5min |
+| `runtime:stream:{user_id}:{conversation_id}` | stream 或 list | SSE 事件历史 | 运行中 24h;结束后 5min |
+| `runtime:stream:done:{user_id}:{conversation_id}` | hash | 完成状态、错误信息、最后 event id | 结束后 5min |
+
+Redis Stream 方案:
+
+- `XADD runtime:stream:{key} * event `
+- `XRANGE` 用于恢复历史。
+- `XTRIM` 或 TTL 控制内存。
+- 每个 SSE chunk 保存原始 `data: ...\n\n` 或结构化 JSON。
+
+List 方案也可行:
+
+- `RPUSH` 保存事件。
+- `LRANGE` 恢复事件。
+- 使用递增 index 作为 event id。
+
+推荐 Redis Stream,因为天然有 event id,适合 `Last-Event-ID`。
+
+#### 4.2.2 改造 StreamingChannelManager
+
+现状:
+
+- `backend/services/streaming_channel.py` 中 `_channels` 是进程内 dict。
+
+改造:
+
+- 保留 `StreamingChannel` API 形状,内部 publish 同时写 Redis。
+- 本地 subscriber 仍可通过 asyncio event 低延迟接收当前 Pod 的事件。
+- 跨 Pod resume 不依赖本地 channel,而是从 Redis 读取历史事件。
+- 本地 channel 只作为实时 fan-out 优化,不作为事实来源。
+
+新增接口:
+
+- `append_stream_event(user_id, conversation_id, chunk) -> event_id`
+- `read_stream_events(user_id, conversation_id, after_event_id=None) -> list`
+- `mark_stream_completed(user_id, conversation_id, status, error=None)`
+- `get_stream_status(user_id, conversation_id)`
+
+兼容策略:
+
+- 现有 `resume=true` 继续支持。
+- 可新增可选 header `Last-Event-ID`,没有 header 时按当前 `resume_from_unit_index` 逻辑兜底。
+- 如果 Redis 缓冲过期,则退化为 DB 已持久化消息恢复,并返回已完成/过期状态。
+
+#### 4.2.3 改造 AgentRunManager
+
+现状:
+
+- `agent_runs` 和 `stop_event` 在本地内存。
+
+改造:
+
+- 注册 run 时写入 Redis:
+ - `owner_pod`: Pod 名,可从环境变量 `HOSTNAME` 获取。
+ - `status`: `running`
+ - `message_id`
+ - `started_at`
+ - `updated_at`
+- agent 主循环周期性检查 Redis cancel key。
+- stop 请求只需要设置 Redis cancel key,不要求打到 owner Pod。
+- owner Pod 检测到 cancel 后触发本地 `stop_event`。
+
+代码修改:
+
+- `backend/agents/agent_run_manager.py`
+- `backend/services/agent_service.py`
+
+关键点:
+
+- 本地 `agent_runs` 仍保留,用于 owner Pod 快速停止。
+- `stop_agent_run` 先尝试本地停止,再写 Redis cancel key。
+- agent run finally 中删除或标记 Redis run 状态。
+- Pod 异常退出时,run key 依赖 TTL 自动过期;消息状态由现有 DB 持久化兜底。
+
+#### 4.2.4 改造 PreprocessManager
+
+现状:
+
+- 预处理任务只在本地保存 task id 和 asyncio task。
+
+改造:
+
+- 对需要跨 Pod 停止的预处理任务写 Redis cancel key。
+- 预处理任务执行过程中定期检查 cancel key。
+- `stop_preprocess_tasks` 写 Redis cancel key,并尝试本地取消。
+
+涉及代码:
+
+- `backend/agents/preprocess_manager.py`
+- 使用 preprocess manager 的调用点。
+
+验收:
+
+- 在 Pod A 发起包含预处理的 agent run。
+- `/agent/stop` 打到 Pod B。
+- Pod A 中预处理和 agent run 均停止。
+
+#### 4.2.5 Conversation ContextManager 策略
+
+现状:
+
+- `AgentRunManager._conversation_context_managers` 保存 conversation 级 `ContextManager`。
+
+第一阶段策略:
+
+- 将它定义为本地优化缓存。
+- 如果当前 Pod 没有缓存,则从 DB conversation history 重建。
+- 不要求跨 Pod 共享 ContextManager 内部压缩缓存。
+
+风险:
+
+- 多 Pod 下同一 conversation 的连续多轮可能落到不同 Pod,压缩缓存命中率下降。
+- 如果有代码依赖 ContextManager 保存未持久化信息,需要在改造前确认并移除该依赖。
+
+### 4.3 后续阶段:MCP 多副本一致性
+
+本节不属于第一阶段交付。第一阶段 `nexent-mcp` 保持单副本;下面内容仅作为后续阶段设计预留。
+
+#### 4.3.1 新增 MCP 启动初始化
+
+现状:
+
+- 动态 OpenAPI MCP 服务只在 refresh 请求到达当前 Pod 时加载。
+
+改造:
+
+- `nexent-mcp` 启动时读取 DB 中 enabled OpenAPI MCP 服务并注册。
+- 启动初始化失败时应记录错误并继续启动 local MCP,避免整个服务不可用。
+
+涉及代码:
+
+- `backend/mcp_service.py`
+- `database/outer_api_tool_db.py`
+
+新增函数:
+
+- `load_all_openapi_services_on_startup()`
+- 或复用 `refresh_openapi_services_by_tenant`,但需要支持所有 tenant。
+
+如果数据库当前只提供按 tenant 查询,需要新增 DB 查询函数:
+
+- `query_all_available_openapi_services()`
+
+#### 4.3.2 Redis Pub/Sub 广播刷新
+
+新增模块:
+
+- `backend/services/mcp_refresh_bus.py`
+
+Redis channel:
+
+- `mcp:refresh`
+
+消息结构:
+
+```json
+{
+ "event": "refresh_all",
+ "tenant_id": "xxx",
+ "service_name": null,
+ "source_pod": "nexent-config-xxx",
+ "timestamp": 1234567890
+}
+```
+
+事件类型:
+
+- `refresh_all`: 刷新某 tenant 的全部 OpenAPI MCP 服务。
+- `refresh_one`: 刷新某 tenant 的某个 service。
+- `delete_one`: 删除某 tenant 的某个 service。
+
+改造流程:
+
+1. config 服务写 DB 成功。
+2. config 服务发布 Redis Pub/Sub 消息。
+3. 所有 mcp Pod 后台订阅。
+4. 每个 mcp Pod 调用本地 refresh 函数更新自己的 FastMCP mount。
+
+涉及代码:
+
+- `backend/services/tool_configuration_service.py`
+- `backend/apps/tool_config_app.py`
+- `backend/mcp_service.py`
+
+降级:
+
+- 如果 Redis Pub/Sub 发布失败,保留当前直接调用 `MCP_MANAGEMENT_API` 的逻辑作为 best effort。
+- 提供管理接口用于手动触发所有 Pod 刷新,或让 mcp Pod 周期性自检 DB 版本。
+
+#### 4.3.3 MCP 配置版本号
+
+为避免 Pub/Sub 消息丢失导致长期不一致,建议增加版本检查。
+
+方案:
+
+- 在 DB 或 Redis 保存 `mcp:version:{tenant_id}`。
+- 每次 OpenAPI MCP 配置变更后 `INCR`。
+- 每个 MCP Pod 维护本地 `loaded_version`。
+- 后台每 30-60 秒检查一次版本,发现版本变化就刷新。
+
+后续阶段可选:
+
+- 如果要压缩工作量,可以先只做 Pub/Sub。
+- 生产可靠性建议同时做版本检查。
+
+### 4.4 Northbound 幂等与限流
+
+#### 4.4.1 幂等键外部化
+
+现状:
+
+- `_IDEMPOTENCY_RUNNING` 是本地 dict。
+
+改造:
+
+- 使用 Redis `SET key value NX EX ttl`。
+- key 格式:`northbound:idempotency:{tenant_id}:{idempotency_key}`。
+- value 保存 request id、pod name、created_at。
+- 请求结束后延迟释放,保持当前 3 秒窗口语义。
+
+涉及代码:
+
+- `backend/services/northbound_service.py`
+
+异常策略:
+
+- Redis 不可用时,为了避免重复执行,建议返回 503 或 429,而不是回退到本地 dict。
+- 如果业务更偏可用性,可以通过配置 `NORTHBOUND_IDEMPOTENCY_FAIL_OPEN=true` 允许降级。
+
+#### 4.4.2 限流外部化
+
+现状:
+
+- `_RATE_STATE` 是本地 dict。
+
+改造:
+
+- 使用 Redis 计数器:
+ - `INCR northbound:rate:{tenant_id}:{minute_bucket}`
+ - 第一次设置 `EXPIRE 120`
+- 超过 `_RATE_LIMIT_PER_MINUTE` 返回 `LimitExceededError`。
+
+新增配置:
+
+- `NORTHBOUND_RATE_LIMIT_PER_MINUTE`
+- `NORTHBOUND_RATE_LIMIT_ENABLED`
+
+注意:
+
+- 后续如果需要更平滑限流,可改为 token bucket 或 sliding window。
+- 第一阶段保持当前 minute bucket 语义。
+
+### 4.5 后续阶段:Auto Summary Scheduler 分布式锁
+
+本节不属于第一阶段交付。第一阶段 `nexent-data-process` 保持单副本,不启用多个 scheduler 实例。
+
+现状:
+
+- `_in_flight` 只在单进程内避免重复。
+
+改造:
+
+- 每个知识库执行前抢 Redis 锁:
+ - `SET auto_summary:lock:{index_name} pod_name NX EX lock_ttl`
+- 抢锁成功才执行。
+- 执行完成后校验 value 是当前 pod 再释放。
+
+涉及代码:
+
+- `backend/services/auto_summary_scheduler.py`
+
+锁 TTL:
+
+- 默认 2 小时,或根据最大摘要任务时间配置。
+- 新增配置 `AUTO_SUMMARY_LOCK_TTL_SECONDS`。
+
+调度模式:
+
+- 后续阶段可以允许多个 Pod 都启动 scheduler,但通过分布式锁保证单个 KB 不重复处理。
+- 更推荐在 Helm 中让 scheduler 拆成独立 deployment,再结合单副本、leader election 或分布式锁运行。
+
+### 4.6 后续阶段:Data Process 拆分预留
+
+第一阶段不做完整 data-process 横扩,但文档和代码应避免阻塞后续拆分。
+
+二期目标形态:
+
+- `nexent-data-process-api`: 只提供 HTTP API。
+- `nexent-data-process-worker`: Celery worker,可按队列和资源扩容。
+- `nexent-ray-head`: Ray head,StatefulSet 或独立 Deployment。
+- `nexent-ray-worker`: Ray worker,支持手工副本数或 KubeRay;HPA 不进入第一阶段。
+- `nexent-flower`: 可选单副本监控。
+- `nexent-auto-summary-scheduler`: 单副本或 leader election。
+
+第一阶段需要避免:
+
+- 在应用层多副本设计中承诺 `nexent-data-process.replicaCount > 1` 可用。
+- 把 Flower/Ray dashboard 多副本暴露成生产入口。
+
+### 4.7 Web 与 Config 多副本
+
+#### 4.7.1 Web 代理层
+
+现状:
+
+- `frontend/server.js` 将 `/api/agent/run`、`/api/agent/stop`、`/api/conversation/`、`/api/share/`、`/api/memory/`、`/api/file/storage` 等代理到 runtime。
+- `/api/voice/` WebSocket upgrade 代理到 runtime。
+- 其他 `/api` 代理到 config。
+- `nexent-web` 不挂载共享 PVC,主要状态来自浏览器 cookie、后端服务和对象存储。
+
+第一阶段改造:
+
+- 为 `nexent-web` Deployment 增加最小 RollingUpdate 策略。
+- 确认 Node proxy 对 SSE 不 buffer。
+- 增加代理超时配置,避免长时间 agent run 被 web 层中断。
+- Web Pod 重启或滚动更新导致 SSE 断开时,前端应能重新连接;恢复语义由 runtime Redis stream/DB 兜底。
+- Web 不保存关键服务端状态,不要求 sticky session。
+
+建议配置:
+
+- `PROXY_TIMEOUT_MS`
+- `PROXY_WS_TIMEOUT_MS`
+- `SSE_PROXY_TIMEOUT_MS`
+
+Ingress:
+
+- 不要求 sticky session。
+- 需要为 SSE/WebSocket 配置合适 timeout:
+ - nginx: `proxy-read-timeout`, `proxy-send-timeout`
+ - 其他 ingress controller 按实际配置。
+
+#### 4.7.2 Config 服务
+
+现状:
+
+- `nexent-config` 挂载 `/mnt/nexent` 和 `/mnt/nexent-data/skills`。
+- Deployment 中 `NEXENT_SQL_STARTUP_MODE` 当前为 `migrate`,多副本启动时可能多个 Pod 同时执行数据库迁移。
+- config 服务写入 Postgres、MinIO 或共享 PVC,并通过 `MCP_MANAGEMENT_API` 通知 `nexent-mcp` 刷新工具。
+- 第一阶段 `nexent-mcp` 仍是单副本,因此 config 多副本不会引入 MCP 多 Pod 工具列表不一致问题。
+
+第一阶段改造:
+
+- 为 `nexent-config` Deployment 增加最小 RollingUpdate 策略。
+- config Pod 不保存影响正确性的进程内状态;如发现本地缓存,只能作为可重建缓存。
+- 数据库迁移必须避免多 Pod 并发执行。推荐方案是把迁移拆到 Helm hook Job 或独立 migration Job,config Deployment 启动时不再执行迁移。
+- 如果短期不拆 migration Job,则需要在迁移入口加数据库级互斥锁,例如 Postgres advisory lock,保证同一时间只有一个 Pod 执行迁移,其它 Pod 等待或跳过。
+- config 多副本写共享文件时要求 `nexent-workspace` 和 `nexent-skills` 使用 RWX 或对象存储化路径;`local + ReadWriteOnce` 不支持跨节点生产多副本。
+- 保持现有 `MCP_MANAGEMENT_API` 调用方式,因为第一阶段 mcp 是单副本。MCP 多副本广播刷新放到后续阶段。
+
+验收:
+
+- 两个 config Pod 同时启动时,数据库迁移不会并发冲突。
+- config API 的创建、更新、删除、查询操作在多 Pod 下读取一致。
+- OpenAPI MCP 配置变更仍能刷新单副本 `nexent-mcp`。
+- 滚动更新期间 config 普通 API 可用,不能因为某个 Pod 正在迁移导致整体不可用。
+
+### 4.8 配置与环境变量
+
+后端新增环境变量必须集中到 `backend/consts/const.py`。Web 代理层环境变量归属前端 Helm chart,不放入后端 `const.py`。
+
+建议新增:
+
+- `RUNTIME_STATE_REDIS_URL`
+ - 默认复用 `REDIS_URL`
+- `RUNTIME_STREAM_TTL_SECONDS`
+- `RUNTIME_RUN_TTL_SECONDS`
+- `RUNTIME_CANCEL_TTL_SECONDS`
+- `RUNTIME_COMPLETED_TTL_SECONDS`
+- `NORTHBOUND_IDEMPOTENCY_TTL_SECONDS`
+- `NORTHBOUND_RATE_LIMIT_ENABLED`
+- `NORTHBOUND_RATE_LIMIT_PER_MINUTE`
+
+Web chart 建议新增或确认:
+
+- `PROXY_TIMEOUT_MS`
+- `PROXY_WS_TIMEOUT_MS`
+- `SSE_PROXY_TIMEOUT_MS`
+
+Config 需要确认:
+
+- `NEXENT_SQL_STARTUP_MODE`
+ - 如果迁移拆成独立 Job,config Deployment 中应关闭启动迁移。
+ - 如果继续在 Pod 启动时迁移,迁移逻辑必须加数据库级互斥锁。
+
+Helm ConfigMap 需要同步:
+
+- `deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml`
+- `deploy/k8s/helm/nexent/charts/nexent-common/values.yaml`
+- `deploy/k8s/helm/nexent/generated-*.yaml` 生成逻辑如有涉及也需更新。
+
+### 4.9 性能影响与优化边界
+
+第一阶段不是零成本扩容。`runtime` 和 `northbound` 会引入 Redis 读写,`web` 和 `config` 会增加代理层、数据库连接数和启动并发控制压力。
+
+Web 主要影响:
+
+- 多个 Web Pod 会增加到 runtime/config Service 的并发代理连接数。
+- SSE/WebSocket 长连接会占用 Web Pod 连接和内存资源。
+- Web Pod 滚动更新会断开该 Pod 上的长连接,依赖前端重连和 runtime resume 保证体验。
+
+Config 主要影响:
+
+- 多个 config Pod 会增加 Postgres、MinIO 和共享存储的并发访问。
+- 如果启动迁移使用数据库锁,Pod 启动时间可能变长。
+- 如果数据库连接池按 Pod 固定大小配置,总连接数会随副本数增长,需要校验 Postgres `max_connections`。
+
+Runtime 主要影响:
+
+- SSE 每个 chunk 需要追加到 Redis stream/list,Redis QPS 会随 agent 输出 token/chunk 数增加。
+- resume 需要从 Redis 读取历史事件,历史越长读取越多。
+- stop/cancel 需要 owner Pod 周期性检查 Redis cancel key,检查间隔越短停止越快,但 Redis 读压力越高。
+- 本地 channel 仍保留为同 Pod 实时 fan-out 优化,正常不断线场景不应全部依赖 Redis 轮询。
+
+Northbound 主要影响:
+
+- 每个带 `Idempotency-Key` 的请求至少增加一次 Redis `SET NX EX`。
+- 每个受限流控制的请求至少增加一次 Redis `INCR/EXPIRE`。
+- Redis 不可用时应优先 fail closed,避免重复执行和限流失效;这会牺牲部分可用性。
+
+优化要求:
+
+- SSE buffer 必须设置 TTL 和最大长度,避免长对话撑爆 Redis 内存。
+- agent run 完成、失败或停止后,主动把 runtime Redis key 的 TTL 缩短到 `RUNTIME_COMPLETED_TTL_SECONDS`。
+- cancel 检查使用合理间隔,例如 0.5-2 秒,不做高频忙轮询。
+- Northbound Redis key 必须设置 TTL,避免幂等键和限流桶长期残留。
+- Config 数据库连接池要按副本数重新核算,避免副本数翻倍后压满 Postgres。
+- Web SSE/WebSocket timeout 要大于典型 agent run 时间,避免代理层提前断开。
+- 压测必须覆盖 Redis QPS、内存占用、P95/P99 延迟和滚动更新期间错误率。
+
+## 5. 兼容性与迁移策略
+
+### 5.1 默认行为
+
+- 默认单副本行为保持不变。
+- 第一阶段 Helm 不引入 HPA/autoscaling,所有副本数通过 `replicaCount` 手工控制。
+
+### 5.2 上线步骤
+
+推荐顺序:
+
+1. 发布 web/config Helm 策略、config 迁移并发保护、runtime/northbound Redis 状态改造代码,但 `replicaCount` 仍保持 1。
+2. 单副本下验证 Web 代理、config CRUD、config migration、agent run、SSE resume、stop、preprocess cancel、northbound 幂等、northbound 限流。
+3. 确认生产环境 Redis 可用,且 config/runtime/northbound 使用的共享存储不是跨节点 `local + ReadWriteOnce`。
+4. 开启 `nexent-web` 多副本,设置 `replicaCount: 2` 和 RollingUpdate 策略,验证登录、页面访问、SSE/WebSocket 代理。
+5. 开启 `nexent-config` 多副本,设置 `replicaCount: 2` 和 RollingUpdate 策略,验证迁移互斥、配置写入、单副本 MCP refresh。
+6. 开启 `nexent-northbound` 多副本,设置 `replicaCount: 2` 和 RollingUpdate 策略。
+7. 开启 `nexent-runtime` 多副本,设置 `replicaCount: 2` 和 RollingUpdate 策略。
+8. 进行滚动更新、Pod 删除、断连重连和跨 Pod stop 故障演练。
+
+### 5.3 回滚策略
+
+- Helm 保留 `replicaCount: 1` 快速回滚方式。
+- Redis 中新增运行状态均设置 TTL,不需要数据库迁移清理。
+- Web 如果代理层出现长连接异常,可以先回滚为单副本,不影响后端状态。
+- Config 如果迁移锁或共享存储写入异常,可以先回滚为单副本,并暂停新的配置写入操作。
+- Runtime 如果 Redis 状态异常,可以停止多副本,保留 DB 中已持久化消息。
+- Northbound 如果 Redis 幂等或限流异常,可以先回滚为单副本,避免重复执行或限流放大。
+
+## 6. 工作量评估
+
+第一阶段包含 `nexent-web`、`nexent-config`、`nexent-runtime` 和 `nexent-northbound` 多副本,预估 20-31 人日。
+
+| 模块 | 工作内容 | 预估 |
+| --- | --- | --- |
+| Helm 多副本模板 | web/config/runtime/northbound 手工 replicaCount、RollingUpdate、生产存储限制说明 | 2-3 人日 |
+| Web 多副本 | 代理超时、SSE/WebSocket 不 buffer、滚动更新断连恢复验证 | 1-2 人日 |
+| Config 多副本 | 启动迁移互斥、共享存储写入确认、MCP 单副本刷新兼容 | 3-5 人日 |
+| Runtime 分布式状态 | Redis stream、run owner、cancel、resume、stop、TTL | 8-12 人日 |
+| Northbound 分布式控制 | Redis 幂等、限流、异常降级策略 | 2-3 人日 |
+| 测试与文档 | 单测、集成测试、k8s 验证、压测、升级文档 | 4-6 人日 |
+
+第二阶段额外工作量:
+
+| 模块 | 工作内容 | 预估 |
+| --- | --- | --- |
+| MCP 一致性 | 启动加载、Pub/Sub 广播、版本检查、管理接口兼容 | 4-6 人日 |
+| Auto Summary Scheduler | scheduler 独立部署、分布式锁或 leader election | 2-4 人日 |
+| Data Process 横向扩展 | API/worker/Ray/Flower/scheduler 拆分 | 15-30 人日 |
+| 内置状态组件 HA | Postgres、Redis、ES、MinIO、Supabase DB 集群化 | 20-40 人日 |
+| 生产观测与容量模型 | 指标、告警、压测模型、容量建议 | 5-10 人日 |
+
+## 7. 测试计划
+
+### 7.1 单元测试
+
+新增或修改测试:
+
+- `test/frontend` 或前端现有代理测试:覆盖 SSE/WebSocket proxy timeout 和不 buffer 行为。
+- `test/backend/apps/test_config_app.py`
+- `test/backend/services/test_tool_configuration_service.py`
+- `test/backend/services/test_runtime_state_service.py`
+- `test/backend/services/test_streaming_channel_distributed.py`
+- `test/backend/agents/test_agent_run_manager.py`
+- `test/backend/services/test_northbound_service.py`
+
+重点场景:
+
+- Redis 写入失败、读取失败、TTL 过期。
+- Web 代理长连接不被提前关闭。
+- config 启动迁移并发时只有一个执行者。
+- config API 多 Pod 下读写 DB 后结果一致。
+- SSE 事件追加和按 event id 恢复。
+- cancel key 被设置后 agent loop 能退出。
+- 幂等 key 并发抢占只有一个成功。
+- 租户级限流在多 Pod 下仍按全局额度生效。
+
+### 7.2 集成测试
+
+本地或 CI 中用 fake Redis/miniredis 或真实 Redis:
+
+- Web 代理到 runtime/config 的 API 正常转发。
+- Web 代理 SSE 断开后可重连。
+- config 多实例同时启动不会并发执行迁移。
+- config 写入 OpenAPI MCP 配置后仍能刷新单副本 mcp。
+- agent run 正常完成。
+- agent run 中断后 resume。
+- stop 请求不在 owner 进程内也能停止。
+- northbound 同一 idempotency key 并发请求只执行一次。
+- northbound 限流请求打到不同 Pod 时仍按同一租户额度计数。
+
+### 7.3 Kubernetes 验证
+
+部署矩阵:
+
+| 服务 | 副本数 |
+| --- | --- |
+| nexent-web | 2 |
+| nexent-config | 2 |
+| nexent-runtime | 2-3 |
+| nexent-northbound | 2 |
+| nexent-mcp | 1 |
+| nexent-data-process | 1 |
+| 状态组件 | 1 或外部托管 |
+
+验证场景:
+
+1. 两个 web Pod 下登录、页面访问、普通 API、SSE 和 WebSocket 代理正常。
+2. 删除一个 web Pod,确认浏览器重连后 agent 流式对话可恢复或得到明确完成状态。
+3. 两个 config Pod 同时启动或滚动更新,确认数据库迁移不并发冲突。
+4. config API 创建、更新、删除智能体配置、模型配置、工具配置后,另一个 Pod 立即读取一致结果。
+5. config 写入 OpenAPI MCP 配置后,确认单副本 `nexent-mcp` 刷新成功。
+6. 发起长 agent SSE,删除处理该请求的 runtime Pod,确认前端能通过 DB/Redis 得到明确恢复结果。
+7. 发起长 agent SSE,断开浏览器连接,再重新连接到不同 runtime Pod,确认继续接收或正确提示已完成。
+8. 发起 agent run 后,将 stop 请求强制打到另一个 runtime Pod,确认运行停止。
+9. northbound 同一 idempotency key 并发请求打到不同 Pod,确认只有一个执行。
+10. northbound 同一租户请求打到不同 Pod,确认限流总额度不随副本数放大。
+11. 手工调大或调小 web/config/runtime/northbound `replicaCount` 后,Web 登录、智能体配置、知识库查询、文件预览仍可用。
+
+### 7.4 压测与容量
+
+基础压测指标:
+
+- agent run 并发数。
+- SSE event 写 Redis 的 QPS 和内存占用。
+- Redis stream/list TTL 清理效果。
+- web Pod 长连接数、代理延迟、断线重连成功率。
+- config Pod DB 连接数、迁移锁等待时间、配置写入延迟。
+- runtime Pod CPU/内存。
+- northbound 限流准确性。
+- northbound Redis 操作延迟和错误率。
+
+建议验收门槛:
+
+- stop 请求跨 Pod 生效小于 2 秒。
+- SSE resume 在 Redis 状态未过期时成功率 100%。
+- web 滚动更新期间长连接可重连,普通页面/API 可用。
+- config 滚动更新期间普通 API 可用,迁移不会并发失败。
+- northbound 幂等键并发抢占成功率符合预期,重复执行次数为 0。
+- 滚动更新期间普通 API 错误率不超过预设阈值。
+
+## 8. 风险与待确认项
+
+### 8.1 Redis 成为关键依赖
+
+runtime/northbound 多副本后,Redis 不再只是 Celery broker/cache,也承载运行控制状态。
+
+需要确认:
+
+- 生产环境是否使用托管 Redis 或 Redis HA。
+- Redis 持久化和内存淘汰策略。
+- Redis 不可用时 runtime/northbound 的失败策略。
+
+建议:
+
+- 第一阶段文档要求生产多副本环境必须提供可靠 Redis。
+- Redis 单实例只适合开发或非关键部署。
+- Redis 自身多副本不在第一阶段 Helm 内置改造范围内。生产推荐三种方式:
+ - 托管 Redis/云 Redis,应用侧仍使用单个稳定连接地址。
+ - Redis Sentinel,一主多从加自动故障转移,应用侧需要确认 Redis client 支持 Sentinel 地址。
+ - Redis Cluster,用于更大容量和分片场景,应用侧需要确认 key 设计、client 和部署网络均支持 cluster mode。
+- 如果继续使用 chart 内置单实例 Redis,则只能视为 runtime/northbound 多副本的功能验证环境,不能视为高可用生产形态。
+
+### 8.2 技能目录共享语义
+
+技能相关代码仍会读写 `SKILLS_PATH`。
+
+需要确认:
+
+- 生产是否提供 RWX PVC。
+- 是否计划将技能文件完全迁移到 MinIO/DB。
+
+第一阶段建议:
+
+- config/runtime/northbound 多副本要求 `nexent-skills` 使用 RWX。
+- 后续单独设计“技能文件对象存储化”。
+
+### 8.3 Config 启动迁移并发
+
+`nexent-config` 当前通过 `NEXENT_SQL_STARTUP_MODE=migrate` 在 Pod 启动时执行迁移。多副本后,如果两个 Pod 同时启动或滚动更新,可能同时执行 SQL migration。
+
+第一阶段建议:
+
+- 优先把迁移拆为独立 Job,在 config Deployment 启动前完成。
+- 如果继续保留 Pod 启动迁移,必须使用 Postgres advisory lock 或等效数据库锁。
+- 迁移失败时 config Pod 不应进入 Ready,避免服务接入到不完整 schema。
+- 滚动更新时必须验证两个 config Pod 不会因为等待迁移锁而全部不可用。
+
+### 8.4 Agent 运行恢复语义
+
+Pod 被删除时,正在运行的 agent 无法真正从中间继续推理,只能:
+
+- 从 DB 返回已持久化部分。
+- 标记为 failed/stopped。
+- 由用户重新发起。
+
+第一阶段目标是“连接恢复和停止控制跨 Pod 可用”,不是“Pod 崩溃后 agent 计算继续执行”。
+
+### 8.5 Data Process 完整横扩
+
+当前 data-process 结构把 API、worker、Ray、Flower、scheduler 放在同一进程树中。
+
+第一阶段不承诺:
+
+- 多个 data-process Pod 同时启动 Ray 后能正确协作。
+- Flower/Ray dashboard 多副本访问一致。
+- Celery worker 和 Ray actor 池按资源自动弹性扩缩。
+
+这些应进入第二阶段。
+
+## 9. 分阶段实施计划
+
+### Milestone 1: 基础设施与配置准备
+
+内容:
+
+- 为 `nexent-web`、`nexent-config`、`nexent-runtime` 和 `nexent-northbound` 新增 Helm 最小多副本字段。
+- 为 web/config/runtime/northbound Deployment 增加 RollingUpdate 策略配置。
+- 新增四个服务的多副本文档和 values 示例。
+- 明确内置状态组件、mcp、data-process 第一阶段保持单副本。
+- 新增 `backend/services/runtime_state_service.py` 基础封装。
+
+验收:
+
+- 单副本行为不变。
+- web/config/runtime/northbound 可渲染多副本 manifests。
+- 生产示例只包含这四个服务的 `replicaCount: 2` 和 RollingUpdate 策略。
+
+预估:3-4 人日。
+
+### Milestone 2: Web 与 Config 多副本
+
+内容:
+
+- Web proxy SSE/WebSocket timeout 和不 buffer 确认。
+- Web 滚动更新断线重连验证。
+- Config 启动迁移拆 Job 或加数据库锁。
+- Config 多 Pod 下配置读写一致性验证。
+- Config 到单副本 MCP 的 refresh 兼容验证。
+
+验收:
+
+- web 2 副本下页面、普通 API、SSE、WebSocket 正常。
+- config 2 副本下迁移不并发冲突,配置读写一致。
+
+预估:4-7 人日。
+
+### Milestone 3: Runtime 多副本
+
+内容:
+
+- SSE event 写 Redis。
+- resume 从 Redis/DB 恢复。
+- agent run owner 和 cancel key 外部化。
+- stop 跨 Pod 生效。
+- preprocess cancel 外部化。
+
+验收:
+
+- runtime 2-3 副本下,run/resume/stop 通过集成测试。
+
+预估:8-12 人日。
+
+### Milestone 4: Northbound 多副本
+
+内容:
+
+- northbound Redis 幂等和限流。
+- Redis 不可用时的失败策略。
+- 多 Pod 并发幂等和全局限流测试。
+
+验收:
+
+- northbound 2 副本下幂等和限流准确。
+
+预估:2-3 人日。
+
+### Milestone 5: Kubernetes 验证、压测与文档
+
+内容:
+
+- web/config/runtime/northbound k8s 多副本验证。
+- 压测和容量记录。
+- 更新安装/升级文档。
+
+验收:
+
+- web/config/runtime/northbound 多副本部署完成全链路 smoke test。
+- 滚动更新期间核心功能可用。
+
+预估:5-8 人日。
+
+## 10. 最终交付物
+
+代码交付:
+
+- Web proxy timeout 和长连接兼容配置。
+- Config migration 并发保护或独立 migration Job。
+- Redis 运行状态服务。
+- Runtime SSE/resume/stop 分布式化。
+- Northbound 分布式幂等与限流。
+- Web/config/runtime/northbound Helm 手工多副本 values 示例和 RollingUpdate 策略配置。
+
+文档交付:
+
+- Kubernetes 多副本设计文档。
+- Kubernetes 安装文档中的 web/config/runtime/northbound 多副本限制说明。
+- Kubernetes 升级文档中的 web/config/runtime/northbound 多副本升级步骤。
+- 运维 runbook:如何扩容、回滚、排查 Redis 状态。
+
+测试交付:
+
+- 后端单元测试。
+- Redis 集成测试。
+- k8s 多副本 smoke test 清单。
+- 压测结果和容量建议。
diff --git a/frontend/server.js b/frontend/server.js
index 4b166f51c..f3c8adcc6 100644
--- a/frontend/server.js
+++ b/frontend/server.js
@@ -33,7 +33,25 @@ const SHARE_BASE_URL =
process.env.SHARE_BASE_URL || process.env.NEXT_PUBLIC_SHARE_BASE_URL || "";
const PORT = 3000;
-const proxy = createProxyServer();
+function parseTimeout(value, fallback) {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+const PROXY_TIMEOUT_MS = parseTimeout(process.env.PROXY_TIMEOUT_MS, 10 * 60 * 1000);
+const PROXY_WS_TIMEOUT_MS = parseTimeout(
+ process.env.PROXY_WS_TIMEOUT_MS,
+ PROXY_TIMEOUT_MS
+);
+const SSE_PROXY_TIMEOUT_MS = parseTimeout(
+ process.env.SSE_PROXY_TIMEOUT_MS,
+ PROXY_TIMEOUT_MS
+);
+
+const proxy = createProxyServer({
+ proxyTimeout: PROXY_TIMEOUT_MS,
+ timeout: PROXY_TIMEOUT_MS,
+});
// ============================================================================
// Cookie configuration
@@ -465,9 +483,14 @@ app.prepare().then(() => {
pathname.startsWith("/api/file/storage") ||
pathname.startsWith("/api/file/preprocess");
if (isRuntime) {
+ const runtimeProxyTimeout = pathname.startsWith("/api/agent/run")
+ ? SSE_PROXY_TIMEOUT_MS
+ : PROXY_TIMEOUT_MS;
proxy.web(req, res, {
target: RUNTIME_HTTP_BACKEND,
changeOrigin: true,
+ proxyTimeout: runtimeProxyTimeout,
+ timeout: runtimeProxyTimeout,
});
} else if (
pathname === "/api/skills/create" ||
@@ -476,9 +499,16 @@ app.prepare().then(() => {
proxy.web(req, res, {
target: RUNTIME_HTTP_BACKEND,
changeOrigin: true,
+ proxyTimeout: PROXY_TIMEOUT_MS,
+ timeout: PROXY_TIMEOUT_MS,
});
} else {
- proxy.web(req, res, { target: HTTP_BACKEND, changeOrigin: true });
+ proxy.web(req, res, {
+ target: HTTP_BACKEND,
+ changeOrigin: true,
+ proxyTimeout: PROXY_TIMEOUT_MS,
+ timeout: PROXY_TIMEOUT_MS,
+ });
}
}
} else {
@@ -495,7 +525,12 @@ app.prepare().then(() => {
req,
socket,
head,
- { target: WS_BACKEND, changeOrigin: true },
+ {
+ target: WS_BACKEND,
+ changeOrigin: true,
+ proxyTimeout: PROXY_WS_TIMEOUT_MS,
+ timeout: PROXY_WS_TIMEOUT_MS,
+ },
(err) => {
console.error("[Proxy] WebSocket Proxy Error:", err);
socket.destroy();
diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py
index 2b22b7a55..a7744f630 100644
--- a/test/backend/services/test_agent_service.py
+++ b/test/backend/services/test_agent_service.py
@@ -14,6 +14,32 @@
# STEP 1: Set up ALL sys.modules mocks BEFORE any backend imports
# =============================================================================
+email_validator_mock = types.ModuleType("email_validator")
+
+
+class MockEmailNotValidError(ValueError):
+ pass
+
+
+def mock_validate_email(email, check_deliverability=False):
+ local_part = email.split("@", 1)[0]
+ return types.SimpleNamespace(normalized=email, local_part=local_part)
+
+email_validator_mock.EmailNotValidError = MockEmailNotValidError
+email_validator_mock.validate_email = mock_validate_email
+sys.modules['email_validator'] = email_validator_mock
+
+try:
+ import pydantic.networks as pydantic_networks
+ original_package_version = pydantic_networks.version
+ pydantic_networks.version = (
+ lambda package_name: "2.0.0"
+ if package_name == "email-validator"
+ else original_package_version(package_name)
+ )
+except Exception:
+ pass
+
# Create mock ToolConfig class with all necessary methods
class MockToolConfig:
"""Mock ToolConfig for testing - accepts any arguments."""
@@ -76,6 +102,18 @@ def model_dump(self, **kwargs):
services_module.__path__ = []
sys.modules['services'] = services_module
+runtime_state_service_module = types.ModuleType("services.runtime_state_service")
+runtime_state_service_mock = MagicMock()
+runtime_state_service_mock.enabled = False
+runtime_state_service_mock.is_cancelled_async = AsyncMock(return_value=False)
+runtime_state_service_mock.get_run_state_async = AsyncMock(return_value={})
+runtime_state_service_mock.read_stream_events_async = AsyncMock(return_value=[])
+runtime_state_service_mock.wait_for_stream_events_async = AsyncMock(return_value=[])
+runtime_state_service_mock.get_stream_status_async = AsyncMock(return_value={})
+runtime_state_service_mock.reset_stream_async = AsyncMock(return_value=None)
+runtime_state_service_module.runtime_state_service = runtime_state_service_mock
+sys.modules['services.runtime_state_service'] = runtime_state_service_module
+
conversation_management_service_mock = MagicMock()
memory_config_service_mock = MagicMock()
agent_version_service_mock = MagicMock()
@@ -4840,7 +4878,7 @@ def fake_save_message(req, user_id, tenant_id, status="completed"):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
unregister_called["user_id"] = user_id
@@ -4895,7 +4933,7 @@ def failing_agent_run(*_, **__):
called = {"unregistered": None, "user_id": None}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
called["unregistered"] = conv_id
called["user_id"] = user_id
@@ -12398,7 +12436,7 @@ def fake_save_message_fail(*args, **kwargs):
# Track unregister calls
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12452,7 +12490,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12519,7 +12557,7 @@ def fake_save_source_image(data, user_id=None):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12590,7 +12628,7 @@ def fake_save_source_search(data, user_id=None):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12674,7 +12712,7 @@ def fake_update_message_status(msg_id, status, user_id):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12756,7 +12794,7 @@ def fake_update_message_status(msg_id, status, user_id):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12837,7 +12875,7 @@ def fake_update_message_status_fail(msg_id, status, user_id):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12900,7 +12938,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -12976,7 +13014,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -13032,7 +13070,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -13085,7 +13123,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -13142,7 +13180,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -13202,7 +13240,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -13263,7 +13301,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -13323,7 +13361,7 @@ def fake_save_message(*args, **kwargs):
unregister_called = {}
- def fake_unregister(conv_id, user_id):
+ def fake_unregister(conv_id, user_id, status="completed"):
unregister_called["conv_id"] = conv_id
monkeypatch.setattr(
@@ -14008,6 +14046,427 @@ async def mock_subscribe():
assert result.status_code == 200
+@pytest.mark.asyncio
+async def test_poll_runtime_cancel_signal_sets_stop_event(monkeypatch):
+ """Redis cancel polling should set the local stop event."""
+ from backend.services import agent_service
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.is_cancelled_async = AsyncMock(side_effect=[False, True])
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+ monkeypatch.setattr(agent_service.asyncio, "sleep", fake_sleep)
+ stop_event = asyncio.Event()
+
+ await agent_service._poll_runtime_cancel_signal(123, "user1", stop_event)
+
+ assert stop_event.is_set()
+ fake_runtime_state.is_cancelled_async.assert_any_await(user_id="user1", conversation_id=123)
+ assert sleeps == [agent_service.RUNTIME_CANCEL_POLL_INTERVAL_SECONDS]
+
+
+@pytest.mark.asyncio
+async def test_poll_runtime_cancel_signal_skips_when_already_stopped(monkeypatch):
+ """Redis cancel polling should not touch Redis when the stop event is already set."""
+ from backend.services import agent_service
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.is_cancelled_async = AsyncMock()
+ stop_event = asyncio.Event()
+ stop_event.set()
+
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+
+ await agent_service._poll_runtime_cancel_signal(123, "user1", stop_event)
+
+ fake_runtime_state.is_cancelled_async.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_cancel_task_on_runtime_signal_cancels_task(monkeypatch):
+ """Redis cancel polling should cancel an active asyncio task."""
+ from backend.services import agent_service
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.is_cancelled_async = AsyncMock(return_value=True)
+ task = MagicMock()
+ task.done.return_value = False
+
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+
+ await agent_service._cancel_task_on_runtime_signal(123, "user1", task)
+
+ task.cancel.assert_called_once()
+ fake_runtime_state.is_cancelled_async.assert_awaited_once_with(user_id="user1", conversation_id=123)
+
+
+@pytest.mark.asyncio
+async def test_cancel_task_on_runtime_signal_waits_then_cancels(monkeypatch):
+ """Redis cancel polling should sleep between checks before cancelling."""
+ from backend.services import agent_service
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.is_cancelled_async = AsyncMock(side_effect=[False, True])
+ task = MagicMock()
+ task.done.return_value = False
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+ monkeypatch.setattr(agent_service.asyncio, "sleep", fake_sleep)
+
+ await agent_service._cancel_task_on_runtime_signal(123, "user1", task)
+
+ task.cancel.assert_called_once()
+ assert sleeps == [agent_service.RUNTIME_CANCEL_POLL_INTERVAL_SECONDS]
+
+
+@pytest.mark.asyncio
+async def test_cancel_task_on_runtime_signal_skips_done_task(monkeypatch):
+ """Redis cancel polling should exit immediately for completed tasks."""
+ from backend.services import agent_service
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.is_cancelled_async = AsyncMock()
+ task = MagicMock()
+ task.done.return_value = True
+
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+
+ await agent_service._cancel_task_on_runtime_signal(123, "user1", task)
+
+ task.cancel.assert_not_called()
+ fake_runtime_state.is_cancelled_async.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_stream_agent_chunks_marks_stopped_when_stop_event_set(monkeypatch):
+ """_stream_agent_chunks should persist stopped terminal status when cancellation wins."""
+ from backend.services import agent_service
+
+ agent_request = MagicMock()
+ agent_request.agent_id = 1
+ agent_request.conversation_id = 999
+ agent_request.query = "test"
+ agent_request.history = []
+ agent_request.minio_files = []
+ agent_request.is_debug = False
+
+ stop_event = asyncio.Event()
+ stop_event.set()
+ agent_run_info = MagicMock()
+ agent_run_info.stop_event = stop_event
+ agent_run_info.query = "test"
+
+ memory_ctx = MagicMock()
+ memory_ctx.user_config.memory_switch = False
+
+ async def fake_agent_run(*_, **__):
+ yield json.dumps({"type": "final_answer", "content": "done"})
+
+ class FakeFuture:
+ def __init__(self, value=None):
+ self.value = value
+
+ def result(self):
+ return self.value
+
+ def fake_submit(fn, *args, **kwargs):
+ return FakeFuture(777)
+
+ statuses = []
+ unregister_calls = []
+
+ monkeypatch.setattr(agent_service, "agent_run", fake_agent_run, raising=False)
+ monkeypatch.setattr(agent_service, "save_message", lambda *args, **kwargs: 4242, raising=False)
+ monkeypatch.setattr(agent_service, "submit", fake_submit, raising=False)
+ monkeypatch.setattr(agent_service, "update_unit_content", lambda *args, **kwargs: None, raising=False)
+ monkeypatch.setattr(agent_service, "update_unit_status", lambda *args, **kwargs: None, raising=False)
+ monkeypatch.setattr(agent_service, "_cleanup_channel_later", AsyncMock(), raising=False)
+ monkeypatch.setattr(
+ agent_service,
+ "update_message_status",
+ lambda message_id, status, user_id: statuses.append((message_id, status, user_id)),
+ raising=False,
+ )
+ monkeypatch.setattr(
+ agent_service.agent_run_manager,
+ "unregister_agent_run",
+ lambda conv_id, user_id, status="completed": unregister_calls.append((conv_id, user_id, status)),
+ raising=False,
+ )
+
+ collected = []
+ async for chunk in agent_service._stream_agent_chunks(
+ agent_request,
+ "user1",
+ "tenant1",
+ agent_run_info,
+ memory_ctx,
+ ):
+ collected.append(chunk)
+
+ assert collected
+ assert statuses[-1] == (4242, "stopped", "user1")
+ assert unregister_calls[-1] == (999, "user1", "stopped")
+
+
+@pytest.mark.asyncio
+async def test_run_agent_stream_resume_remote_running_uses_runtime_stream(monkeypatch):
+ """Resume should replay Redis stream events when the run lives on another replica."""
+ from backend.services import agent_service
+
+ agent_request = MagicMock()
+ agent_request.agent_id = 1
+ agent_request.conversation_id = 999
+ agent_request.query = "test"
+ agent_request.history = []
+ agent_request.minio_files = []
+ agent_request.is_debug = False
+ agent_request.resume = True
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.get_run_state_async = AsyncMock(side_effect=[
+ {"status": "running"},
+ {"status": "completed"},
+ ])
+ fake_runtime_state.read_stream_events_async = AsyncMock(return_value=[
+ ("1-0", 'data: {"type": "model_output", "content": "old"}\n\n'),
+ ("2-0", ""),
+ ])
+ fake_runtime_state.wait_for_stream_events_async = AsyncMock(return_value=[
+ ("3-0", 'data: {"type": "final_answer", "content": "new"}\n\n'),
+ ])
+ fake_runtime_state.get_stream_status_async = AsyncMock(return_value={"status": "completed"})
+
+ with patch(
+ "backend.services.agent_service._resolve_user_tenant_language",
+ return_value=("user1", "tenant1", "en"),
+ ), \
+ patch("backend.services.agent_service._detect_resume_position") as mock_detect, \
+ patch("backend.services.agent_service.agent_run_manager") as mock_mgr, \
+ patch("backend.services.agent_service.streaming_channel_manager") as mock_channel_mgr:
+ mock_detect.return_value = {
+ "should_resume": True,
+ "message_id": 1,
+ "message_status": "streaming",
+ "resume_from_unit_index": 5,
+ "reason": "backend_streaming",
+ }
+ mock_mgr.get_agent_run_info.return_value = None
+ mock_channel_mgr.get_channel.return_value = None
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+
+ result = await agent_service.run_agent_stream(
+ agent_request,
+ MagicMock(),
+ "Bearer token",
+ resume=True,
+ )
+
+ chunks = []
+ async for chunk in result.body_iterator:
+ chunks.append(chunk)
+
+ assert result.status_code == 200
+ assert result.headers["X-Stream-Status"] == "resumed"
+ assert result.headers["X-Last-Unit-Index"] == "5"
+ assert chunks[0] == agent_service.STREAM_STATUS_EVENT
+ assert '"replay_chunk_count": 2' in chunks[1]
+ assert "old" in "".join(chunks)
+ assert "new" in "".join(chunks)
+ assert '"status": "completed"' in chunks[-1]
+ fake_runtime_state.wait_for_stream_events_async.assert_awaited_once_with(
+ user_id="user1",
+ conversation_id=999,
+ last_id="2-0",
+ )
+
+
+@pytest.mark.asyncio
+async def test_run_agent_stream_resume_remote_running_uses_run_state_terminal_status(monkeypatch):
+ """Redis resume should stop from run state when no stream completion hash exists."""
+ from backend.services import agent_service
+
+ agent_request = MagicMock()
+ agent_request.agent_id = 1
+ agent_request.conversation_id = 999
+ agent_request.query = "test"
+ agent_request.history = []
+ agent_request.minio_files = []
+ agent_request.is_debug = False
+ agent_request.resume = True
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.get_run_state_async = AsyncMock(side_effect=[
+ {"status": "running"},
+ {"status": "stopped"},
+ ])
+ fake_runtime_state.read_stream_events_async = AsyncMock(return_value=[])
+ fake_runtime_state.wait_for_stream_events_async = AsyncMock(return_value=[
+ ("1-0", ""),
+ ])
+ fake_runtime_state.get_stream_status_async = AsyncMock(return_value={})
+
+ with patch(
+ "backend.services.agent_service._resolve_user_tenant_language",
+ return_value=("user1", "tenant1", "en"),
+ ), \
+ patch("backend.services.agent_service._detect_resume_position") as mock_detect, \
+ patch("backend.services.agent_service.agent_run_manager") as mock_mgr, \
+ patch("backend.services.agent_service.streaming_channel_manager") as mock_channel_mgr:
+ mock_detect.return_value = {
+ "should_resume": True,
+ "message_id": 1,
+ "message_status": "streaming",
+ "resume_from_unit_index": 5,
+ "reason": "backend_streaming",
+ }
+ mock_mgr.get_agent_run_info.return_value = None
+ mock_channel_mgr.get_channel.return_value = None
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+
+ result = await agent_service.run_agent_stream(
+ agent_request,
+ MagicMock(),
+ "Bearer token",
+ resume=True,
+ )
+
+ chunks = []
+ async for chunk in result.body_iterator:
+ chunks.append(chunk)
+
+ assert result.status_code == 200
+ assert chunks[0] == agent_service.STREAM_STATUS_EVENT
+ assert '"replay_chunk_count": 0' in chunks[1]
+ assert '"status": "stopped"' in chunks[-1]
+
+
+@pytest.mark.asyncio
+async def test_run_agent_stream_resume_channel_body_yields_completed_status(monkeypatch):
+ """Local channel resume should yield replay metadata, chunks, and completed status."""
+ from backend.services import agent_service
+
+ agent_request = MagicMock()
+ agent_request.agent_id = 1
+ agent_request.conversation_id = 999
+ agent_request.query = "test"
+ agent_request.history = []
+ agent_request.minio_files = []
+ agent_request.is_debug = False
+ agent_request.resume = True
+
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.get_run_state_async = AsyncMock(return_value={})
+
+ mock_channel = MagicMock()
+ mock_channel.history_size = 1
+
+ async def mock_subscribe(_start_index):
+ yield 'data: {"type": "final_answer", "content": "from-channel"}\n\n'
+
+ mock_channel.subscribe_with_history = mock_subscribe
+
+ with patch(
+ "backend.services.agent_service._resolve_user_tenant_language",
+ return_value=("user1", "tenant1", "en"),
+ ), \
+ patch("backend.services.agent_service._detect_resume_position") as mock_detect, \
+ patch("backend.services.agent_service.agent_run_manager") as mock_mgr, \
+ patch("backend.services.agent_service.streaming_channel_manager") as mock_channel_mgr:
+ mock_detect.return_value = {
+ "should_resume": True,
+ "message_id": 1,
+ "message_status": "streaming",
+ "resume_from_unit_index": 5,
+ "reason": "backend_streaming",
+ }
+ mock_mgr.get_agent_run_info.return_value = MagicMock()
+ mock_channel_mgr.get_channel.return_value = mock_channel
+ monkeypatch.setattr(agent_service, "runtime_state_service", fake_runtime_state)
+
+ result = await agent_service.run_agent_stream(
+ agent_request,
+ MagicMock(),
+ "Bearer token",
+ resume=True,
+ )
+
+ chunks = []
+ async for chunk in result.body_iterator:
+ chunks.append(chunk)
+
+ assert result.status_code == 200
+ assert chunks[0] == agent_service.STREAM_STATUS_EVENT
+ assert '"replay_chunk_count": 1' in chunks[1]
+ assert "from-channel" in chunks[2]
+ assert chunks[-2] == agent_service.STREAM_STATUS_EVENT
+ assert '"status": "completed"' in chunks[-1]
+
+
+@pytest.mark.asyncio
+async def test_generate_stream_with_memory_handles_missing_current_task(monkeypatch):
+ """Memory streaming should work even if asyncio.current_task returns None."""
+ from backend.services import agent_service
+
+ agent_request = MagicMock()
+ agent_request.agent_id = 1
+ agent_request.conversation_id = 999
+ agent_request.query = "test"
+ agent_request.history = []
+ agent_request.minio_files = []
+ agent_request.is_debug = False
+
+ fake_channel = MagicMock()
+ fake_channel.publish = AsyncMock()
+ memory_preview = MagicMock()
+ memory_preview.user_config.memory_switch = False
+ memory_ctx = MagicMock()
+ agent_run_info = MagicMock()
+
+ async def fake_stream_agent_chunks(**_kwargs):
+ yield "data: done\n\n"
+
+ fake_preprocess_manager = MagicMock()
+ monkeypatch.setattr(agent_service.asyncio, "current_task", lambda: None)
+ monkeypatch.setattr(agent_service, "preprocess_manager", fake_preprocess_manager)
+ monkeypatch.setattr(
+ agent_service.streaming_channel_manager,
+ "get_or_create_channel",
+ AsyncMock(return_value=fake_channel),
+ raising=False,
+ )
+ monkeypatch.setattr(agent_service, "build_memory_context", lambda *args, **kwargs: memory_preview)
+ monkeypatch.setattr(
+ agent_service,
+ "prepare_agent_run",
+ AsyncMock(return_value=(agent_run_info, memory_ctx)),
+ )
+ monkeypatch.setattr(agent_service, "_stream_agent_chunks", fake_stream_agent_chunks)
+
+ chunks = []
+ async for chunk in agent_service.generate_stream_with_memory(
+ agent_request,
+ "user1",
+ "tenant1",
+ "en",
+ ):
+ chunks.append(chunk)
+
+ assert chunks == ["data: done\n\n"]
+ fake_preprocess_manager.register_preprocess_task.assert_not_called()
+ fake_preprocess_manager.unregister_preprocess_task.assert_called_once()
+
+
def test_validate_requested_output_tokens_no_requested_tokens():
"""_validate_requested_output_tokens_for_agent should return when requested_output_tokens is None."""
diff --git a/test/backend/services/test_northbound_service.py b/test/backend/services/test_northbound_service.py
index df3fc32ea..01a6ae99e 100644
--- a/test/backend/services/test_northbound_service.py
+++ b/test/backend/services/test_northbound_service.py
@@ -41,6 +41,15 @@ class ConversationNotFoundError(Exception):
# Mock consts.const
consts_const_mod = types.ModuleType("consts.const")
consts_const_mod.ASSET_OWNER_TENANT_ID = "asset-owner-tenant"
+consts_const_mod.RUNTIME_STATE_REDIS_URL = ""
+consts_const_mod.RUNTIME_STREAM_TTL_SECONDS = 86400
+consts_const_mod.RUNTIME_STREAM_MAX_LEN = 10000
+consts_const_mod.RUNTIME_RUN_TTL_SECONDS = 86400
+consts_const_mod.RUNTIME_CANCEL_TTL_SECONDS = 86400
+consts_const_mod.RUNTIME_COMPLETED_TTL_SECONDS = 300
+consts_const_mod.NORTHBOUND_IDEMPOTENCY_TTL_SECONDS = 600
+consts_const_mod.NORTHBOUND_RATE_LIMIT_ENABLED = True
+consts_const_mod.NORTHBOUND_RATE_LIMIT_PER_MINUTE = 120
sys.modules["consts.const"] = consts_const_mod
# Mock consts package
@@ -92,6 +101,15 @@ class ConversationNotFoundError(Exception):
# Mock services modules
services_package = types.ModuleType("services")
+# Mock runtime_state_service
+runtime_state_service_mod = types.ModuleType("services.runtime_state_service")
+runtime_state_service_mod.runtime_state_service = MagicMock()
+runtime_state_service_mod.runtime_state_service.enabled = False
+runtime_state_service_mod.runtime_state_service.acquire_idempotency_async = AsyncMock(return_value=True)
+runtime_state_service_mod.runtime_state_service.release_idempotency_async = AsyncMock()
+runtime_state_service_mod.runtime_state_service.consume_rate_limit_async = AsyncMock(return_value=1)
+sys.modules["services.runtime_state_service"] = runtime_state_service_mod
+
# Mock agent_service
agent_service_mod = types.ModuleType("services.agent_service")
agent_service_mod.run_agent_stream = AsyncMock()
@@ -128,6 +146,7 @@ class ConversationNotFoundError(Exception):
services_package.agent_version_service = agent_version_mod
services_package.conversation_management_service = conv_mgmt_mod
services_package.file_management_service = file_mgmt_mod
+services_package.runtime_state_service = runtime_state_service_mod
sys.modules["services"] = services_package
# Mock consts.model - create stub classes
@@ -287,20 +306,67 @@ async def test_idempotency_end_removes_key(self):
@pytest.mark.asyncio
async def test_idempotency_end_nonexistent_key(self):
"""Test that ending nonexistent key does not raise."""
- await ns.idempotency_end("nonexistent-key") # Should not raise
+ await ns.idempotency_end("nonexistent-key")
@pytest.mark.asyncio
async def test_idempotency_expired_key_can_be_reused(self, reset_test_isolation):
"""Test that expired keys can be reused after TTL."""
- # Use a very short TTL
await ns.idempotency_start("expire-key", ttl_seconds=1)
assert "expire-key" in ns._IDEMPOTENCY_RUNNING
- # Wait for expiration
import asyncio
await asyncio.sleep(1.1)
- # Should be able to start again with same key
await ns.idempotency_start("expire-key", ttl_seconds=1)
+ @pytest.mark.asyncio
+ async def test_idempotency_uses_redis_when_enabled(self):
+ """Test Redis-backed idempotency path."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.acquire_idempotency_async = AsyncMock(return_value=True)
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ await ns.idempotency_start("redis-key")
+
+ fake_runtime_state.acquire_idempotency_async.assert_awaited_once_with(
+ "redis-key",
+ ns.NORTHBOUND_IDEMPOTENCY_TTL_SECONDS,
+ )
+
+ @pytest.mark.asyncio
+ async def test_idempotency_redis_duplicate_raises(self):
+ """Test Redis-backed idempotency rejects duplicate in-flight requests."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.acquire_idempotency_async = AsyncMock(return_value=False)
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ with pytest.raises(LimitExceededError, match="Duplicate request"):
+ await ns.idempotency_start("redis-key")
+
+ @pytest.mark.asyncio
+ async def test_idempotency_redis_error_fails_closed(self):
+ """Test Redis errors make idempotency fail closed."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.acquire_idempotency_async = AsyncMock(side_effect=RuntimeError("redis down"))
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ with pytest.raises(LimitExceededError, match="Idempotency service is unavailable"):
+ await ns.idempotency_start("redis-key")
+
+ @pytest.mark.asyncio
+ async def test_idempotency_end_uses_redis_and_swallows_release_error(self, caplog):
+ """Test Redis-backed idempotency release path and warning handling."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.release_idempotency_async = AsyncMock(side_effect=RuntimeError("release failed"))
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ await ns.idempotency_end("redis-key")
+
+ fake_runtime_state.release_idempotency_async.assert_awaited_once_with("redis-key")
+ assert "Northbound idempotency release failed" in caplog.text
+
class TestRateLimiting:
"""Tests for rate limiting functionality."""
@@ -321,12 +387,61 @@ async def test_rate_limit_multiple_requests(self):
@pytest.mark.asyncio
async def test_rate_limit_exceeded_raises(self):
"""Test that exceeding limit raises LimitExceededError."""
- # Fill up to limit
- for _ in range(ns._RATE_LIMIT_PER_MINUTE):
+ for _ in range(ns.NORTHBOUND_RATE_LIMIT_PER_MINUTE):
await ns.check_and_consume_rate_limit("tenant-limit")
with pytest.raises(LimitExceededError):
await ns.check_and_consume_rate_limit("tenant-limit")
+ @pytest.mark.asyncio
+ async def test_rate_limit_uses_redis_when_enabled(self):
+ """Test Redis-backed rate limit path."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.consume_rate_limit_async = AsyncMock(return_value=1)
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ await ns.check_and_consume_rate_limit("tenant-redis")
+
+ fake_runtime_state.consume_rate_limit_async.assert_awaited_once_with(
+ tenant_id="tenant-redis",
+ limit_per_minute=ns.NORTHBOUND_RATE_LIMIT_PER_MINUTE,
+ )
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_disabled_returns_without_state(self):
+ """Test disabled rate limit avoids both Redis and local counters."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.consume_rate_limit_async = AsyncMock()
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state), \
+ patch.object(ns, "NORTHBOUND_RATE_LIMIT_ENABLED", False):
+ await ns.check_and_consume_rate_limit("tenant-disabled")
+
+ fake_runtime_state.consume_rate_limit_async.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_redis_value_error_maps_to_limit_exceeded(self):
+ """Test Redis rate-limit over-quota result maps to the API exception."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.consume_rate_limit_async = AsyncMock(side_effect=ValueError("rate limit exceeded"))
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ with pytest.raises(LimitExceededError, match="Query rate exceeded"):
+ await ns.check_and_consume_rate_limit("tenant-redis")
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_redis_error_fails_closed(self):
+ """Test Redis errors make rate limiting fail closed."""
+ fake_runtime_state = MagicMock()
+ fake_runtime_state.enabled = True
+ fake_runtime_state.consume_rate_limit_async = AsyncMock(side_effect=RuntimeError("redis down"))
+
+ with patch.object(ns, "runtime_state_service", fake_runtime_state):
+ with pytest.raises(LimitExceededError, match="Rate limit service is unavailable"):
+ await ns.check_and_consume_rate_limit("tenant-redis")
+
@pytest.mark.asyncio
async def test_rate_limit_different_tenants(self):
"""Test that different tenants have separate limits."""
@@ -340,14 +455,11 @@ async def test_rate_limit_different_tenants(self):
@pytest.mark.asyncio
async def test_rate_limit_cleanup_old_buckets(self):
"""Test that old minute buckets are cleaned up."""
- # First, add a request to create an old bucket
old_bucket = str(int(ns._now_seconds() // 60) - 1)
ns._RATE_STATE["tenant-cleanup"] = {old_bucket: 50}
- # Make a new request - should trigger cleanup of old bucket
await ns.check_and_consume_rate_limit("tenant-cleanup")
- # Old bucket should be cleaned up, new bucket should have 1 request
current_bucket = ns._minute_bucket()
assert old_bucket not in ns._RATE_STATE["tenant-cleanup"]
assert ns._RATE_STATE["tenant-cleanup"].get(current_bucket, 0) == 1
diff --git a/test/backend/services/test_runtime_state_service.py b/test/backend/services/test_runtime_state_service.py
new file mode 100644
index 000000000..bf787c927
--- /dev/null
+++ b/test/backend/services/test_runtime_state_service.py
@@ -0,0 +1,444 @@
+import asyncio
+
+import pytest
+
+from backend.services import runtime_state_service as runtime_state_module
+from backend.services.runtime_state_service import RuntimeStateService
+
+
+class FakeRedisClient:
+ def __init__(self):
+ self.hsets = []
+ self.expires = []
+ self.deletes = []
+ self.setexes = []
+ self.sets = []
+ self.xadds = []
+ self.xranges = []
+ self.xreads = []
+ self.hashes = {}
+ self.values = {}
+ self.stream_events = []
+ self.fail_next = set()
+ self.pipeline_result = (1, True)
+
+ def _maybe_fail(self, method):
+ if method in self.fail_next:
+ self.fail_next.remove(method)
+ raise RuntimeError(f"{method} failed")
+
+ def hset(self, key, mapping):
+ self._maybe_fail("hset")
+ self.hsets.append((key, mapping))
+ self.hashes.setdefault(key, {}).update(mapping)
+
+ def expire(self, key, ttl):
+ self._maybe_fail("expire")
+ self.expires.append((key, ttl))
+
+ def delete(self, *keys):
+ self._maybe_fail("delete")
+ self.deletes.append(keys)
+ for key in keys:
+ self.hashes.pop(key, None)
+ self.values.pop(key, None)
+
+ def hgetall(self, key):
+ self._maybe_fail("hgetall")
+ return dict(self.hashes.get(key, {}))
+
+ def setex(self, key, ttl, value):
+ self._maybe_fail("setex")
+ self.setexes.append((key, ttl, value))
+ self.values[key] = value
+ return True
+
+ def get(self, key):
+ self._maybe_fail("get")
+ return self.values.get(key)
+
+ def xadd(self, key, values, maxlen=None, approximate=False):
+ self._maybe_fail("xadd")
+ event_id = f"{len(self.stream_events) + 1}-0"
+ self.xadds.append((key, values, maxlen, approximate))
+ self.stream_events.append((event_id, values))
+ return event_id
+
+ def xrange(self, key, min="-"):
+ self._maybe_fail("xrange")
+ self.xranges.append((key, min))
+ if min == "-":
+ return list(self.stream_events)
+ if min.startswith("("):
+ after_id = min[1:]
+ return [(event_id, values) for event_id, values in self.stream_events if event_id > after_id]
+ return list(self.stream_events)
+
+ def xread(self, streams, count=100, block=1000):
+ self._maybe_fail("xread")
+ self.xreads.append((streams, count, block))
+ if not self.stream_events:
+ return []
+ return [(next(iter(streams.keys())), list(self.stream_events[:count]))]
+
+ def set(self, key, value, nx=False, ex=None):
+ self._maybe_fail("set")
+ self.sets.append((key, value, nx, ex))
+ if nx and key in self.values:
+ return False
+ self.values[key] = value
+ return True
+
+ def pipeline(self):
+ return FakePipeline(self)
+
+
+class FakePipeline:
+ def __init__(self, client):
+ self.client = client
+ self.operations = []
+
+ def incr(self, key):
+ self.operations.append(("incr", key))
+ return self
+
+ def expire(self, key, ttl):
+ self.operations.append(("expire", key, ttl))
+ return self
+
+ def execute(self):
+ self.client._maybe_fail("pipeline")
+ return self.client.pipeline_result
+
+
+class TestRuntimeStateService(RuntimeStateService):
+ def __init__(self, client):
+ super().__init__()
+ self._fake_client = client
+
+ @property
+ def enabled(self):
+ return True
+
+ @property
+ def client(self):
+ return self._fake_client
+
+
+def test_mark_run_finished_shortens_completed_runtime_key_ttls(monkeypatch):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_COMPLETED_TTL_SECONDS", 300)
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ service.mark_run_finished("user-1", 42, "completed")
+
+ assert client.hsets[0][0] == "runtime:run:user-1:42"
+ assert client.hsets[0][1]["status"] == "completed"
+ assert set(client.expires) == {
+ ("runtime:run:user-1:42", 300),
+ ("runtime:cancel:user-1:42", 300),
+ ("runtime:stream:user-1:42", 300),
+ ("runtime:stream:done:user-1:42", 300),
+ }
+
+
+def test_mark_stream_completed_shortens_completed_runtime_key_ttls(monkeypatch):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_COMPLETED_TTL_SECONDS", 300)
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ service.mark_stream_completed("user-1", 42, "failed", error="model error")
+
+ assert client.hsets[0][0] == "runtime:stream:done:user-1:42"
+ assert client.hsets[0][1]["status"] == "failed"
+ assert client.hsets[0][1]["error"] == "model error"
+ assert set(client.expires) == {
+ ("runtime:run:user-1:42", 300),
+ ("runtime:cancel:user-1:42", 300),
+ ("runtime:stream:user-1:42", 300),
+ ("runtime:stream:done:user-1:42", 300),
+ }
+
+
+def test_disabled_service_methods_return_without_client(monkeypatch):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_STATE_REDIS_URL", "")
+ service = RuntimeStateService()
+
+ assert service.enabled is False
+ assert service.get_run_state("user-1", 42) == {}
+ assert service.set_cancel_signal("user-1", 42) is False
+ assert service.is_cancelled("user-1", 42) is False
+ assert service.append_stream_event("user-1", 42, "chunk") is None
+ assert service.get_stream_status("user-1", 42) == {}
+ assert service.read_stream_events("user-1", 42) == []
+ assert service.wait_for_stream_events("user-1", 42, "0-0") == []
+ service.reset_stream("user-1", 42)
+ service.register_run("user-1", 42)
+ service.mark_run_finished("user-1", 42, "completed")
+ service.mark_stream_completed("user-1", 42, "completed")
+
+
+def test_client_property_requires_redis_url(monkeypatch):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_STATE_REDIS_URL", "")
+ service = RuntimeStateService()
+
+ with pytest.raises(ValueError, match="RUNTIME_STATE_REDIS_URL"):
+ _ = service.client
+
+
+def test_client_property_requires_redis_package(monkeypatch):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_STATE_REDIS_URL", "redis://test")
+ monkeypatch.setattr(runtime_state_module, "redis", None)
+ service = RuntimeStateService()
+
+ with pytest.raises(ValueError, match="redis package"):
+ _ = service.client
+
+
+def test_client_property_lazily_creates_redis_client(monkeypatch):
+ fake_client = FakeRedisClient()
+ fake_redis = type("FakeRedisModule", (), {
+ "from_url": staticmethod(lambda url, **kwargs: fake_client)
+ })
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_STATE_REDIS_URL", "redis://test")
+ monkeypatch.setattr(runtime_state_module, "redis", fake_redis)
+ service = RuntimeStateService()
+
+ assert service.client is fake_client
+ assert service.client is fake_client
+
+
+def test_reset_stream_deletes_stream_and_done_keys():
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ service.reset_stream("user-1", 42)
+
+ assert client.deletes == [("runtime:stream:user-1:42", "runtime:stream:done:user-1:42")]
+
+
+def test_reset_stream_swallows_redis_errors(caplog):
+ client = FakeRedisClient()
+ client.fail_next.add("delete")
+ service = TestRuntimeStateService(client)
+
+ service.reset_stream("user-1", 42)
+
+ assert "Failed to reset runtime stream state" in caplog.text
+
+
+def test_register_run_writes_owner_status_ttl_and_clears_cancel(monkeypatch):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_RUN_TTL_SECONDS", 123)
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ service.register_run("user-1", 42, message_id=99)
+
+ key, mapping = client.hsets[0]
+ assert key == "runtime:run:user-1:42"
+ assert mapping["status"] == "running"
+ assert mapping["message_id"] == "99"
+ assert ("runtime:run:user-1:42", 123) in client.expires
+ assert ("runtime:cancel:user-1:42",) in client.deletes
+
+
+def test_register_run_swallows_redis_errors(caplog):
+ client = FakeRedisClient()
+ client.fail_next.add("hset")
+ service = TestRuntimeStateService(client)
+
+ service.register_run("user-1", 42)
+
+ assert "Failed to register runtime run state" in caplog.text
+
+
+def test_get_run_state_returns_hash_and_handles_errors(caplog):
+ client = FakeRedisClient()
+ client.hashes["runtime:run:user-1:42"] = {"status": "running"}
+ service = TestRuntimeStateService(client)
+
+ assert service.get_run_state("user-1", 42) == {"status": "running"}
+
+ client.fail_next.add("hgetall")
+ assert service.get_run_state("user-1", 42) == {}
+ assert "Failed to get runtime run state" in caplog.text
+
+
+def test_mark_run_finished_swallows_redis_errors(caplog):
+ client = FakeRedisClient()
+ client.fail_next.add("hset")
+ service = TestRuntimeStateService(client)
+
+ service.mark_run_finished("user-1", 42, "failed")
+
+ assert "Failed to mark runtime run state as finished" in caplog.text
+
+
+def test_cancel_signal_set_and_read_with_error_paths(caplog):
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ assert service.set_cancel_signal("user-1", 42) is True
+ assert client.setexes[0][0] == "runtime:cancel:user-1:42"
+ assert service.is_cancelled("user-1", 42) is True
+
+ client.fail_next.add("setex")
+ assert service.set_cancel_signal("user-1", 42) is False
+ client.fail_next.add("get")
+ assert service.is_cancelled("user-1", 42) is False
+ assert "Failed to set runtime cancel signal" in caplog.text
+ assert "Failed to read runtime cancel signal" in caplog.text
+
+
+def test_append_stream_event_success_and_error(monkeypatch, caplog):
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_STREAM_MAX_LEN", 10)
+ monkeypatch.setattr(runtime_state_module, "RUNTIME_STREAM_TTL_SECONDS", 20)
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ event_id = service.append_stream_event("user-1", 42, "chunk")
+
+ assert event_id == "1-0"
+ assert client.xadds == [("runtime:stream:user-1:42", {"chunk": "chunk"}, 10, True)]
+ assert ("runtime:stream:user-1:42", 20) in client.expires
+
+ client.fail_next.add("xadd")
+ assert service.append_stream_event("user-1", 42, "chunk") is None
+ assert "Failed to append runtime stream event" in caplog.text
+
+
+def test_mark_stream_completed_without_error_and_error_path(caplog):
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ service.mark_stream_completed("user-1", 42, "completed")
+
+ assert client.hsets[0][0] == "runtime:stream:done:user-1:42"
+ assert client.hsets[0][1]["status"] == "completed"
+ assert "error" not in client.hsets[0][1]
+
+ client.fail_next.add("hset")
+ service.mark_stream_completed("user-1", 42, "failed")
+ assert "Failed to mark runtime stream completed" in caplog.text
+
+
+def test_stream_status_returns_hash_and_handles_errors(caplog):
+ client = FakeRedisClient()
+ client.hashes["runtime:stream:done:user-1:42"] = {"status": "completed"}
+ service = TestRuntimeStateService(client)
+
+ assert service.get_stream_status("user-1", 42) == {"status": "completed"}
+
+ client.fail_next.add("hgetall")
+ assert service.get_stream_status("user-1", 42) == {}
+ assert "Failed to get runtime stream status" in caplog.text
+
+
+def test_read_stream_events_with_and_without_after_id_and_error(caplog):
+ client = FakeRedisClient()
+ client.stream_events = [
+ ("1-0", {"chunk": "first"}),
+ ("2-0", {"other": "missing chunk"}),
+ ]
+ service = TestRuntimeStateService(client)
+
+ assert service.read_stream_events("user-1", 42) == [("1-0", "first"), ("2-0", "")]
+ assert client.xranges[-1] == ("runtime:stream:user-1:42", "-")
+ assert service.read_stream_events("user-1", 42, after_id="1-0") == [("2-0", "")]
+ assert client.xranges[-1] == ("runtime:stream:user-1:42", "(1-0")
+
+ client.fail_next.add("xrange")
+ assert service.read_stream_events("user-1", 42) == []
+ assert "Failed to read runtime stream events" in caplog.text
+
+
+def test_wait_for_stream_events_success_empty_and_error(caplog):
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ assert service.wait_for_stream_events("user-1", 42, "0-0") == []
+
+ client.stream_events = [("1-0", {"chunk": "first"}), ("2-0", {"other": "missing"})]
+ assert service.wait_for_stream_events("user-1", 42, "0-0", block_ms=5, count=2) == [
+ ("1-0", "first"),
+ ("2-0", ""),
+ ]
+ assert client.xreads[-1] == ({"runtime:stream:user-1:42": "0-0"}, 2, 5)
+
+ client.fail_next.add("xread")
+ assert service.wait_for_stream_events("user-1", 42, "0-0") == []
+ assert "Failed to wait for runtime stream events" in caplog.text
+
+
+def test_idempotency_acquire_and_release():
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+ redis_key = service._idempotency_key("request-key")
+
+ assert service.acquire_idempotency("request-key", 60) is True
+ assert client.sets[-1] == (redis_key, service._pod_name, True, 60)
+ assert service.acquire_idempotency("request-key", 60) is False
+
+ service.release_idempotency("request-key")
+
+ assert (redis_key,) in client.deletes
+
+
+def test_consume_rate_limit_success_and_limit_exceeded(monkeypatch):
+ monkeypatch.setattr(runtime_state_module.time, "time", lambda: 120.0)
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ assert service.consume_rate_limit("tenant-1", 2) == 1
+
+ client.pipeline_result = (3, True)
+ with pytest.raises(ValueError, match="rate limit exceeded"):
+ service.consume_rate_limit("tenant-1", 2)
+
+
+def test_async_wrappers_delegate_to_sync_methods(monkeypatch):
+ client = FakeRedisClient()
+ service = TestRuntimeStateService(client)
+
+ async def fake_to_thread(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ monkeypatch.setattr(runtime_state_module.asyncio, "to_thread", fake_to_thread)
+ monkeypatch.setattr(
+ service,
+ "reset_stream",
+ lambda user_id, conversation_id: client.values.setdefault("reset", True),
+ )
+ monkeypatch.setattr(service, "get_run_state", lambda user_id, conversation_id: {"status": "running"})
+ monkeypatch.setattr(service, "is_cancelled", lambda user_id, conversation_id: True)
+ monkeypatch.setattr(service, "append_stream_event", lambda user_id, conversation_id, chunk: "1-0")
+ monkeypatch.setattr(
+ service,
+ "mark_stream_completed",
+ lambda *args, **kwargs: client.values.setdefault("done", True),
+ )
+ monkeypatch.setattr(service, "get_stream_status", lambda user_id, conversation_id: {"status": "completed"})
+ monkeypatch.setattr(
+ service,
+ "read_stream_events",
+ lambda user_id, conversation_id, after_id=None: [("1-0", "chunk")],
+ )
+ monkeypatch.setattr(service, "wait_for_stream_events", lambda *args, **kwargs: [("2-0", "chunk2")])
+ monkeypatch.setattr(service, "acquire_idempotency", lambda key, ttl_seconds: True)
+ monkeypatch.setattr(service, "release_idempotency", lambda key: client.values.setdefault("released", key))
+ monkeypatch.setattr(service, "consume_rate_limit", lambda tenant_id, limit_per_minute: 1)
+
+ async def run_checks():
+ await service.reset_stream_async("user-1", 42)
+ assert await service.get_run_state_async("user-1", 42) == {"status": "running"}
+ assert await service.is_cancelled_async("user-1", 42) is True
+ assert await service.append_stream_event_async("user-1", 42, "chunk") == "1-0"
+ await service.mark_stream_completed_async("user-1", 42, "completed")
+ assert await service.get_stream_status_async("user-1", 42) == {"status": "completed"}
+ assert await service.read_stream_events_async("user-1", 42) == [("1-0", "chunk")]
+ assert await service.wait_for_stream_events_async("user-1", 42, "1-0") == [("2-0", "chunk2")]
+ assert await service.acquire_idempotency_async("request-key", 60) is True
+ await service.release_idempotency_async("request-key")
+ assert await service.consume_rate_limit_async("tenant-1", 2) == 1
+
+ asyncio.run(run_checks())
From e341ff5ddd1742af7671306b6b9bc0efcb58d32d Mon Sep 17 00:00:00 2001
From: Summer-Si <140902149+Summer-Si@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:09:36 +0800
Subject: [PATCH 21/26] =?UTF-8?q?=E2=9C=A8=20Feature:=20add=20skill=20repo?=
=?UTF-8?q?sitory=20lifecycle=20and=20management=20UI=20(#3393)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add skill repository backend APIs
* Add skill repository frontend flow
* Refine skill repository build UI
* feat: add skill edit modal state
* feat: improve skill repository copy, edit, and review flows
* feat: refine agent config skill management
* test: add skill repository coverage and remove dead code
* fix: harden skill editing and repository workflow
* fix: address skill repository quality gates
* fix: correct skill path validation
* fix: stabilize skill path test setup
* test: cover skill update by id workflow
* fix: resolve skill repository quality gate issues
* fix: test
---------
Co-authored-by: sumi2234
---
backend/apps/config_app.py | 2 +
backend/apps/skill_app.py | 94 +-
backend/apps/skill_repository_app.py | 263 ++++
backend/consts/exceptions.py | 7 +-
backend/consts/model.py | 34 +
backend/database/db_models.py | 36 +-
backend/database/skill_db.py | 164 +-
backend/database/skill_repository_db.py | 333 ++++
backend/services/skill_repository_service.py | 884 +++++++++++
backend/services/skill_service.py | 191 ++-
backend/utils/skill_params_utils.py | 2 +-
...v2.3.0_0629_add_skill_repository_table.sql | 99 ++
.../agentConfig/SkillBuildModal.tsx | 1378 +++++++----------
.../agentConfig/SkillDetailModal.tsx | 547 ++-----
.../agentConfig/SkillDraftPanel.tsx | 488 ++++++
.../components/upload/UploadAreaUI.tsx | 2 +-
.../components/CreateNewSkillCard.tsx | 23 +
.../skill-space/components/MineSkillsView.tsx | 411 +++++
.../skill-space/components/RepositoryView.tsx | 120 ++
.../components/ReviewSkillList.tsx | 170 ++
.../components/SkillRepositoryCard.tsx | 113 ++
.../components/SkillRepositoryControls.tsx | 136 ++
.../components/SkillRepositoryDetailModal.tsx | 145 ++
.../components/SkillReviewStatusModal.tsx | 148 ++
.../components/skillRepositoryShared.ts | 80 +
frontend/app/[locale]/skill-space/page.tsx | 662 +++++++-
frontend/hooks/agent/useSkillList.ts | 140 +-
.../useSkillRepositoryListings.ts | 136 ++
frontend/public/locales/en/common.json | 9 +-
frontend/public/locales/zh/common.json | 18 +-
frontend/services/agentConfigService.ts | 136 +-
frontend/services/api.ts | 60 +
frontend/services/skillRepositoryService.ts | 204 +++
frontend/services/skillService.ts | 169 +-
frontend/types/agentConfig.ts | 66 +-
frontend/types/skillRepository.ts | 116 ++
test/backend/app/test_skill_app.py | 235 +++
test/backend/app/test_skill_repository_app.py | 386 +++++
test/backend/database/test_skill_db.py | 220 +++
.../database/test_skill_repository_db.py | 260 ++++
.../services/test_skill_repository_service.py | 755 +++++++++
test/backend/services/test_skill_service.py | 588 ++++++-
test/backend/utils/test_skill_params_utils.py | 8 +
43 files changed, 8460 insertions(+), 1578 deletions(-)
create mode 100644 backend/apps/skill_repository_app.py
create mode 100644 backend/database/skill_repository_db.py
create mode 100644 backend/services/skill_repository_service.py
create mode 100644 deploy/sql/migrations/v2.3.0_0629_add_skill_repository_table.sql
create mode 100644 frontend/app/[locale]/agents/components/agentConfig/SkillDraftPanel.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/CreateNewSkillCard.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/MineSkillsView.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/RepositoryView.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/ReviewSkillList.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/SkillRepositoryCard.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/SkillRepositoryControls.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/SkillRepositoryDetailModal.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/SkillReviewStatusModal.tsx
create mode 100644 frontend/app/[locale]/skill-space/components/skillRepositoryShared.ts
create mode 100644 frontend/hooks/skillRepository/useSkillRepositoryListings.ts
create mode 100644 frontend/services/skillRepositoryService.ts
create mode 100644 frontend/types/skillRepository.ts
create mode 100644 test/backend/app/test_skill_repository_app.py
create mode 100644 test/backend/database/test_skill_repository_db.py
create mode 100644 test/backend/services/test_skill_repository_service.py
create mode 100644 test/backend/utils/test_skill_params_utils.py
diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py
index bade3eea1..b8998f19f 100644
--- a/backend/apps/config_app.py
+++ b/backend/apps/config_app.py
@@ -3,6 +3,7 @@
from apps.app_factory import create_app
from apps.agent_app import agent_config_router as agent_router
from apps.agent_repository_app import agent_repository_router
+from apps.skill_repository_app import skill_repository_router
from apps.config_sync_app import router as config_sync_router
from apps.datamate_app import router as datamate_router
from apps.vectordatabase_app import router as vectordatabase_router
@@ -61,6 +62,7 @@ async def sync_default_prompt_template_on_startup():
app.include_router(config_sync_router)
app.include_router(agent_router)
app.include_router(agent_repository_router)
+app.include_router(skill_repository_router)
app.include_router(vectordatabase_router)
app.include_router(datamate_router)
app.include_router(voice_router)
diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py
index 5a67cafd5..7cc5b9f82 100644
--- a/backend/apps/skill_app.py
+++ b/backend/apps/skill_app.py
@@ -10,7 +10,7 @@
from pydantic import BaseModel, Field
from consts.const import APP_VERSION, STREAMABLE_CONTENT_TYPES
-from consts.exceptions import SkillException, UnauthorizedError
+from consts.exceptions import ForbiddenError, SkillException, UnauthorizedError
from services.skill_service import (
SkillService,
skill_creation_task_manager,
@@ -25,6 +25,7 @@
ASSET_OWNER_SKILL_VIEW_DENIED = {"content": "您无权限查看"}
logger = logging.getLogger(__name__)
+_NOT_FOUND_TEXT = "not found"
router = APIRouter(prefix="/skills", tags=["skills"])
skill_creator_router = APIRouter(prefix="/skills", tags=["nl2skill"])
@@ -37,6 +38,25 @@ def _asset_owner_skill_view_denied_response(skill: Optional[Dict[str, Any]], ten
return None
+def _build_skill_update_data(request: SkillUpdateRequest) -> Dict[str, Any]:
+ update_data: Dict[str, Any] = {}
+ for field_name in (
+ "name",
+ "description",
+ "content",
+ "tags",
+ "source",
+ "config_schemas",
+ "config_values",
+ ):
+ value = getattr(request, field_name)
+ if value is not None:
+ update_data[field_name] = value
+ if request.files is not None:
+ update_data["files"] = [f.model_dump() for f in request.files]
+ return update_data
+
+
# List routes first (no path parameters)
@router.get("")
async def list_skills(
@@ -169,7 +189,7 @@ async def create_skill_from_file(
file: UploadFile = File(..., description="SKILL.md file or ZIP archive"),
skill_name: Optional[str] = Form(
None, description="Optional skill name override"),
- source: Optional[str] = Form("自定义", description="Skill source"),
+ source: Optional[str] = Form("custom", description="Skill source"),
authorization: Optional[str] = Header(None)
) -> JSONResponse:
"""Create a skill from file upload.
@@ -323,7 +343,7 @@ async def update_skill_from_file(
except UnauthorizedError as e:
raise HTTPException(status_code=401, detail=str(e))
except SkillException as e:
- if "not found" in str(e).lower():
+ if _NOT_FOUND_TEXT in str(e).lower():
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -496,6 +516,72 @@ async def scan_and_update_skill(authorization: Optional[str] = Header(None)):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update skill")
+@router.get("/{skill_id:int}")
+async def get_skill_by_id(skill_id: int, authorization: Optional[str] = Header(None)) -> JSONResponse:
+ """Get a specific skill by ID."""
+ try:
+ _, tenant_id = get_current_user_id(authorization)
+ service = SkillService(tenant_id=tenant_id)
+ skill = service.get_skill_by_id(skill_id, tenant_id=tenant_id)
+ if not skill:
+ raise HTTPException(
+ status_code=404, detail=f"Skill not found: {skill_id}")
+ return JSONResponse(content=skill)
+ except HTTPException:
+ raise
+ except SkillException as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ except Exception as e:
+ logger.exception("Error getting skill by ID %s", skill_id)
+ raise HTTPException(status_code=500, detail="Internal server error")
+
+
+@router.put(
+ "/{skill_id:int}",
+ responses={
+ 400: {"description": "No fields to update or invalid skill data"},
+ 401: {"description": "Unauthorized"},
+ 403: {"description": "Not authorized to update this skill"},
+ 404: {"description": "Skill not found"},
+ 500: {"description": "Internal server error"},
+ },
+)
+async def update_skill_by_id(
+ skill_id: int,
+ request: SkillUpdateRequest,
+ authorization: Optional[str] = Header(None)
+) -> JSONResponse:
+ """Update an existing skill by ID."""
+ try:
+ user_id, tenant_id = get_current_user_id(authorization)
+ service = SkillService(tenant_id=tenant_id)
+ update_data = _build_skill_update_data(request)
+
+ if not update_data:
+ raise HTTPException(status_code=400, detail="No fields to update")
+
+ skill = service.update_skill_by_id(
+ skill_id,
+ update_data,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ )
+ return JSONResponse(content=skill)
+ except UnauthorizedError as e:
+ raise HTTPException(status_code=401, detail=str(e))
+ except ForbiddenError as e:
+ raise HTTPException(status_code=403, detail=str(e))
+ except SkillException as e:
+ if _NOT_FOUND_TEXT in str(e).lower():
+ raise HTTPException(status_code=404, detail=str(e))
+ raise HTTPException(status_code=400, detail=str(e))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.exception("Error updating skill by ID %s", skill_id)
+ raise HTTPException(status_code=500, detail="Internal server error")
+
+
@router.get("/{skill_name}")
async def get_skill(skill_name: str, authorization: Optional[str] = Header(None)) -> JSONResponse:
"""Get a specific skill by name."""
@@ -558,7 +644,7 @@ async def update_skill(
except UnauthorizedError as e:
raise HTTPException(status_code=401, detail=str(e))
except SkillException as e:
- if "not found" in str(e).lower():
+ if _NOT_FOUND_TEXT in str(e).lower():
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
diff --git a/backend/apps/skill_repository_app.py b/backend/apps/skill_repository_app.py
new file mode 100644
index 000000000..108642f6a
--- /dev/null
+++ b/backend/apps/skill_repository_app.py
@@ -0,0 +1,263 @@
+import logging
+from http import HTTPStatus
+from typing import Annotated, Optional
+
+from fastapi import APIRouter, Body, Header, HTTPException, Query
+from starlette.responses import JSONResponse
+
+from consts.exceptions import ForbiddenError, SkillDuplicateError, UnauthorizedError
+from consts.model import SkillRepositoryInstallRequest, SkillRepositoryListingCreateRequest
+from services.skill_repository_service import (
+ create_skill_repository_listing_impl,
+ get_skill_repository_listing_detail_impl,
+ install_skill_from_repository_impl,
+ list_my_editable_skills_impl,
+ list_skill_repository_listings_impl,
+ update_skill_repository_status_impl,
+)
+from utils.auth_utils import get_current_user_id
+
+logger = logging.getLogger(__name__)
+skill_repository_router = APIRouter(prefix="/repository/skill")
+
+
+@skill_repository_router.get("")
+async def list_skill_repository_listings_api(
+ status: Optional[str] = Query(
+ None, description="Filter by listing status"),
+ skill_id: Optional[int] = Query(
+ None, description="Filter by source skill ID"),
+ category_id: Optional[int] = Query(
+ None,
+ description="Filter by marketplace category ID",
+ ),
+ page: Annotated[int, Query(
+ ge=1, description="Page number starting from 1")] = 1,
+ page_size: Annotated[
+ int, Query(ge=1, le=100, description="Page size from 1 to 100")
+ ] = 10,
+ search: Optional[str] = Query(
+ None, description="Filter by name, description, source, submitter, or tags"
+ ),
+ sort_by_update_time: bool = Query(
+ False, description="Sort by repository update time descending"
+ ),
+ authorization: str = Header(None),
+):
+ """List all skill marketplace repository listings with optional filters."""
+ try:
+ _, tenant_id = get_current_user_id(authorization)
+ result = list_skill_repository_listings_impl(
+ tenant_id,
+ status=status,
+ skill_id=skill_id,
+ category_id=category_id,
+ page=page,
+ page_size=page_size,
+ search=search,
+ sort_by_update_time=sort_by_update_time,
+ )
+ return JSONResponse(status_code=HTTPStatus.OK, content=result)
+ except UnauthorizedError as e:
+ logger.warning(
+ f"Unauthorized skill repository listings access attempt: {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
+ except ValueError as e:
+ logger.warning(
+ f"Invalid skill repository listings request parameters: {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
+
+
+@skill_repository_router.get("/mine")
+async def list_my_editable_skills_api(
+ ownership: Optional[str] = Query(
+ "all",
+ description="Filter by ownership: all / created / others",
+ ),
+ page: Annotated[int, Query(
+ ge=1, description="Page number starting from 1")] = 1,
+ page_size: Annotated[
+ int, Query(ge=1, le=100, description="Page size from 1 to 100")
+ ] = 10,
+ search: Optional[str] = Query(
+ None, description="Filter by skill name, description, source, creator, or tags"
+ ),
+ new_skill_padding: bool = Query(
+ False,
+ description="Reserve first slot on page 1 for create-skill placeholder",
+ ),
+ authorization: str = Header(None),
+):
+ """List editable skills for the current user with repository listing info."""
+ try:
+ user_id, tenant_id = get_current_user_id(authorization)
+ result = list_my_editable_skills_impl(
+ tenant_id=tenant_id,
+ user_id=user_id,
+ ownership=ownership or "all",
+ page=page,
+ page_size=page_size,
+ search=search,
+ new_skill_padding=new_skill_padding,
+ )
+ return JSONResponse(status_code=HTTPStatus.OK, content=result)
+ except UnauthorizedError as e:
+ logger.warning(
+ f"Unauthorized my editable skills access attempt: {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
+ except ValueError as e:
+ logger.warning(
+ f"Invalid my editable skills request parameters (ownership={ownership}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
+
+
+@skill_repository_router.get("/{skill_repository_id}")
+async def get_skill_repository_listing_detail_api(
+ skill_repository_id: int,
+ authorization: str = Header(None),
+):
+ """Get detailed skill marketplace repository listing by primary key."""
+ try:
+ _, tenant_id = get_current_user_id(authorization)
+ result = get_skill_repository_listing_detail_impl(
+ skill_repository_id,
+ tenant_id,
+ )
+ return JSONResponse(status_code=HTTPStatus.OK, content=result)
+ except UnauthorizedError as e:
+ logger.warning(
+ f"Unauthorized skill repository listing detail access attempt "
+ f"(id={skill_repository_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
+ except ValueError as e:
+ logger.warning(
+ f"Skill repository listing not found (id={skill_repository_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e))
+
+
+@skill_repository_router.patch("/{skill_repository_id}/status")
+async def update_skill_repository_status_api(
+ skill_repository_id: int,
+ status: str = Body(
+ ...,
+ embed=True,
+ description=(
+ "New status: not_shared / pending_review / rejected / shared"
+ ),
+ ),
+ authorization: str = Header(None),
+):
+ """Update skill marketplace repository listing status."""
+ try:
+ user_id, tenant_id = get_current_user_id(authorization)
+ result = update_skill_repository_status_impl(
+ skill_repository_id=skill_repository_id,
+ status=status,
+ user_id=user_id,
+ tenant_id=tenant_id,
+ )
+ return JSONResponse(status_code=HTTPStatus.OK, content=result)
+ except UnauthorizedError as e:
+ logger.warning(
+ f"Unauthorized skill repository status update attempt "
+ f"(id={skill_repository_id}, status={status}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
+ except ForbiddenError as e:
+ logger.warning(
+ f"Forbidden skill repository status update attempt "
+ f"(id={skill_repository_id}, status={status}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
+ except ValueError as e:
+ logger.warning(
+ f"Invalid skill repository status update "
+ f"(id={skill_repository_id}, status={status}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
+
+
+@skill_repository_router.post("/{skill_id}")
+async def create_skill_repository_listing_api(
+ skill_id: int,
+ payload: Optional[SkillRepositoryListingCreateRequest] = Body(None),
+ authorization: str = Header(None),
+):
+ """Create or update a marketplace repository listing from a skill snapshot."""
+ try:
+ user_id, tenant_id = get_current_user_id(authorization)
+ card_fields = payload.model_dump(
+ exclude_none=True) if payload else None
+ result = create_skill_repository_listing_impl(
+ skill_id=skill_id,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ card_fields=card_fields,
+ )
+ return JSONResponse(status_code=HTTPStatus.OK, content=result)
+ except UnauthorizedError as e:
+ logger.warning(
+ f"Unauthorized skill repository listing creation attempt "
+ f"(skill_id={skill_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
+ except ForbiddenError as e:
+ logger.warning(
+ f"Forbidden skill repository listing creation attempt "
+ f"(skill_id={skill_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
+ except ValueError as e:
+ logger.warning(
+ f"Invalid skill repository listing creation parameters "
+ f"(skill_id={skill_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
+
+
+@skill_repository_router.post("/{skill_repository_id}/install")
+async def install_skill_from_repository_api(
+ skill_repository_id: int,
+ payload: Optional[SkillRepositoryInstallRequest] = Body(None),
+ authorization: Optional[str] = Header(None),
+):
+ """Install a skill from a shared marketplace repository listing."""
+ try:
+ user_id, tenant_id = get_current_user_id(authorization)
+ result = install_skill_from_repository_impl(
+ skill_repository_id=skill_repository_id,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ target_name=payload.target_name if payload else None,
+ )
+ return JSONResponse(status_code=HTTPStatus.OK, content=result)
+ except UnauthorizedError as e:
+ logger.warning(
+ f"Unauthorized skill repository install attempt "
+ f"(id={skill_repository_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
+ except ValueError as e:
+ logger.warning(
+ f"Skill repository listing not found for install "
+ f"(id={skill_repository_id}): {str(e)}"
+ )
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e))
+ except SkillDuplicateError as e:
+ logger.warning(
+ f"Duplicate skill repository install attempt "
+ f"(id={skill_repository_id}, duplicates={e.duplicate_names})"
+ )
+ raise HTTPException(
+ status_code=HTTPStatus.CONFLICT,
+ detail={
+ "type": "skill_duplicate",
+ "duplicate_skills": e.duplicate_names,
+ },
+ )
diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py
index e5e4c7a89..54fce6d82 100644
--- a/backend/consts/exceptions.py
+++ b/backend/consts/exceptions.py
@@ -95,6 +95,12 @@ class UnauthorizedError(Exception):
pass
+class ForbiddenError(Exception):
+ """Raised when an authenticated user lacks permission."""
+
+ pass
+
+
class SignatureValidationError(Exception):
"""Raised when X-Signature header is missing or does not match the expected HMAC value."""
@@ -286,7 +292,6 @@ class UnsupportedOperationError(Exception):
# Common aliases
ParameterInvalidError = ValidationError
-ForbiddenError = Exception # Generic fallback
ServiceUnavailableError = Exception # Generic fallback
DatabaseError = Exception # Generic fallback
TimeoutError = TimeoutException
diff --git a/backend/consts/model.py b/backend/consts/model.py
index e8061b4f8..b3b784b0f 100644
--- a/backend/consts/model.py
+++ b/backend/consts/model.py
@@ -740,6 +740,39 @@ class AgentRepositoryListingDetailResponse(BaseModel):
tools: List[str] = Field(default_factory=list)
+class SkillRepositoryListingCreateRequest(BaseModel):
+ """Request body for creating a marketplace listing from a skill snapshot."""
+ icon: Optional[str] = Field(None, description="Marketplace card icon (emoji or URL)")
+ downloads: int = Field(0, ge=0, description="Initial download count for card display")
+ tags: Optional[List[str]] = Field(None, description="Marketplace tags")
+ category_id: Optional[int] = Field(0, description="Optional marketplace category ID")
+
+
+class SkillRepositoryInstallRequest(BaseModel):
+ """Request body for installing a repository skill into current tenant."""
+ target_name: Optional[str] = Field(None, description="Target skill name in current tenant")
+
+
+class SkillRepositoryListingDetailResponse(BaseModel):
+ """Detailed marketplace listing payload for skill repository detail view."""
+ skill_repository_id: int
+ skill_id: Optional[int] = None
+ name: str
+ description: Optional[str] = None
+ source: Optional[str] = None
+ submitted_by: Optional[str] = None
+ icon: Optional[str] = None
+ status: str
+ category_id: Optional[int] = None
+ tags: List[str] = Field(default_factory=list)
+ downloads: int = 0
+ created_at: Optional[str] = None
+ content: Optional[str] = None
+ config_schemas: Optional[Dict[str, Any]] = None
+ config_values: Optional[Dict[str, Any]] = None
+ tool_ids: List[int] = Field(default_factory=list)
+
+
class SkillZipEntry(BaseModel):
"""A skill bundled inside an agent export ZIP."""
skill_name: str
@@ -1260,6 +1293,7 @@ class SkillFileData(BaseModel):
class SkillUpdateRequest(BaseModel):
"""Request model for updating a skill."""
+ name: Optional[str] = None
description: Optional[str] = None
content: Optional[str] = None
tool_ids: Optional[List[int]] = None
diff --git a/backend/database/db_models.py b/backend/database/db_models.py
index 3c078d9b4..55068e553 100644
--- a/backend/database/db_models.py
+++ b/backend/database/db_models.py
@@ -13,6 +13,8 @@
# Shared doc strings for primary key columns
_PRIMARY_KEY_DOC = "Primary key, auto-increment"
_TENANT_ID_DOC = "Tenant ID for multi-tenancy isolation"
+_PUBLISHER_TENANT_ID_DOC = "Publisher tenant ID"
+_PUBLISHER_USER_ID_DOC = "Publisher user ID"
# Base class for tables without audit fields
@@ -672,8 +674,8 @@ class McpCommunityRecord(TableBase):
nullable=False,
doc="Community record ID, unique primary key",
)
- tenant_id = Column(String(100), doc="Publisher tenant ID")
- user_id = Column(String(100), doc="Publisher user ID")
+ tenant_id = Column(String(100), doc=_PUBLISHER_TENANT_ID_DOC)
+ user_id = Column(String(100), doc=_PUBLISHER_USER_ID_DOC)
mcp_name = Column(String(100), doc="MCP name")
mcp_server = Column(String(500), doc="MCP server URL")
source = Column(String(30), doc="Source type, fixed to community")
@@ -860,8 +862,8 @@ class AgentRepository(TableBase):
agent_repository_id = Column(BigInteger, Sequence("ag_agent_repository_t_agent_repository_id_seq", schema=SCHEMA),
primary_key=True, nullable=False, doc="Agent repository listing ID, unique primary key")
- publisher_tenant_id = Column(String(100), nullable=False, doc="Publisher tenant ID")
- publisher_user_id = Column(String(100), nullable=False, doc="Publisher user ID")
+ publisher_tenant_id = Column(String(100), nullable=False, doc=_PUBLISHER_TENANT_ID_DOC)
+ publisher_user_id = Column(String(100), nullable=False, doc=_PUBLISHER_USER_ID_DOC)
agent_id = Column(Integer, nullable=False,
doc="Root agent ID from ag_tenant_agent_t; upsert key")
version_no = Column(Integer, nullable=False,
@@ -886,6 +888,32 @@ class AgentRepository(TableBase):
doc="Listing status: not_shared (未共享) / pending_review (待审核) / rejected (审核驳回) / shared (已共享)")
+class SkillRepository(TableBase):
+ """
+ Skill repository (marketplace) table. Frozen snapshot of a shared skill for installation.
+ """
+ __tablename__ = "ag_skill_repository_t"
+ __table_args__ = {"schema": SCHEMA}
+
+ skill_repository_id = Column(BigInteger, Sequence("ag_skill_repository_t_skill_repository_id_seq", schema=SCHEMA),
+ primary_key=True, nullable=False, doc="Skill repository listing ID, unique primary key")
+ publisher_tenant_id = Column(String(100), nullable=False, doc=_PUBLISHER_TENANT_ID_DOC)
+ publisher_user_id = Column(String(100), nullable=False, doc=_PUBLISHER_USER_ID_DOC)
+ skill_id = Column(Integer, nullable=False, doc="Source skill ID from ag_skill_info_t")
+ name = Column(String(100), nullable=False, doc="Skill name for display and search")
+ description = Column(Text, doc="Skill description")
+ source = Column(String(30), doc="Skill source")
+ submitted_by = Column(String(100), doc="Submitter email when listing enters pending_review")
+ category_id = Column(Integer, doc="Optional marketplace category ID")
+ tags = Column(ARRAY(Text), doc="Marketplace tags")
+ icon = Column(String(100), doc="Marketplace card icon (emoji or URL)")
+ downloads = Column(Integer, default=0, doc="Marketplace install count for card display")
+ skill_info_json = Column(JSONB, nullable=False, doc="Frozen skill metadata snapshot")
+ skill_zip_base64 = Column(Text, nullable=False, doc="Frozen skill ZIP payload encoded as base64")
+ status = Column(String(30), default="not_shared",
+ doc="Listing status: not_shared / pending_review / rejected / shared")
+
+
class UserTokenInfo(TableBase):
"""
User token (AK/SK) information table
diff --git a/backend/database/skill_db.py b/backend/database/skill_db.py
index 6a3f69069..8f51849bb 100644
--- a/backend/database/skill_db.py
+++ b/backend/database/skill_db.py
@@ -35,7 +35,8 @@ def create_or_update_skill_by_skill_info(skill_info, tenant_id: str, user_id: st
Returns:
Created or updated SkillInstance object
"""
- skill_info_dict = skill_info.__dict__ if hasattr(skill_info, '__dict__') else skill_info
+ skill_info_dict = skill_info.__dict__ if hasattr(
+ skill_info, '__dict__') else skill_info
skill_info_dict = skill_info_dict.copy()
skill_info_dict.setdefault("tenant_id", tenant_id)
skill_info_dict.setdefault("user_id", user_id)
@@ -178,7 +179,6 @@ def delete_skill_instances_by_tenant(tenant_id: str, user_id: str) -> int:
return count
-
# ============== SkillInfo Repository Functions ==============
@@ -190,6 +190,46 @@ def _get_tool_ids(session, skill_id: int) -> List[int]:
return [r.tool_id for r in relations]
+def _build_skill_update_values(
+ skill_data: Dict[str, Any],
+ updated_by: Optional[str],
+) -> Dict[str, Any]:
+ row_values: Dict[str, Any] = {"update_time": datetime.now()}
+ if updated_by:
+ row_values["updated_by"] = updated_by
+
+ field_mapping = {
+ "description": "skill_description",
+ "content": "skill_content",
+ "tags": "skill_tags",
+ "source": "source",
+ }
+ for input_field, model_field in field_mapping.items():
+ if input_field in skill_data:
+ row_values[model_field] = skill_data[input_field]
+
+ for field in ("config_schemas", "config_values"):
+ if field in skill_data:
+ row_values[field] = _params_value_for_db(skill_data[field])
+ return row_values
+
+
+def _replace_skill_tool_relations(
+ session,
+ skill_id: int,
+ tool_ids: List[int],
+) -> None:
+ session.query(SkillToolRelation).filter(
+ SkillToolRelation.skill_id == skill_id
+ ).delete()
+ for tool_id in tool_ids:
+ session.add(SkillToolRelation(
+ skill_id=skill_id,
+ tool_id=tool_id,
+ create_time=datetime.now(),
+ ))
+
+
def _to_dict(skill: SkillInfo) -> Dict[str, Any]:
"""Convert SkillInfo to dict."""
return {
@@ -323,8 +363,10 @@ def create_skill(skill_data: Dict[str, Any], tenant_id: str) -> Dict[str, Any]:
skill_description=skill_data.get("description", ""),
skill_tags=skill_data.get("tags", []),
skill_content=skill_data.get("content", ""),
- config_schemas=_params_value_for_db(skill_data.get("config_schemas")),
- config_values=_params_value_for_db(skill_data.get("config_values")),
+ config_schemas=_params_value_for_db(
+ skill_data.get("config_schemas")),
+ config_values=_params_value_for_db(
+ skill_data.get("config_values")),
source=skill_data.get("source", "custom"),
created_by=skill_data.get("created_by"),
create_time=datetime.now(),
@@ -383,23 +425,7 @@ def update_skill(
raise ValueError(f"Skill not found: {skill_name}")
skill_id = skill.skill_id
- now = datetime.now()
- row_values: Dict[str, Any] = {"update_time": now}
- if updated_by:
- row_values["updated_by"] = updated_by
-
- if "description" in skill_data:
- row_values["skill_description"] = skill_data["description"]
- if "content" in skill_data:
- row_values["skill_content"] = skill_data["content"]
- if "tags" in skill_data:
- row_values["skill_tags"] = skill_data["tags"]
- if "source" in skill_data:
- row_values["source"] = skill_data["source"]
- if "config_schemas" in skill_data:
- row_values["config_schemas"] = _params_value_for_db(skill_data["config_schemas"])
- if "config_values" in skill_data:
- row_values["config_values"] = _params_value_for_db(skill_data["config_values"])
+ row_values = _build_skill_update_values(skill_data, updated_by)
session.execute(
sa_update(SkillInfo)
@@ -411,17 +437,11 @@ def update_skill(
)
if "tool_ids" in skill_data:
- session.query(SkillToolRelation).filter(
- SkillToolRelation.skill_id == skill_id
- ).delete()
-
- for tool_id in skill_data["tool_ids"]:
- rel = SkillToolRelation(
- skill_id=skill_id,
- tool_id=tool_id,
- create_time=datetime.now()
- )
- session.add(rel)
+ _replace_skill_tool_relations(
+ session,
+ skill_id,
+ skill_data["tool_ids"],
+ )
session.commit()
@@ -441,6 +461,74 @@ def update_skill(
return result
+def update_skill_by_id(
+ skill_id: int,
+ skill_data: Dict[str, Any],
+ tenant_id: str,
+ updated_by: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Update an existing skill by ID for a tenant."""
+ with get_db_session() as session:
+ skill = session.query(SkillInfo).filter(
+ SkillInfo.skill_id == skill_id,
+ SkillInfo.tenant_id == tenant_id,
+ SkillInfo.delete_flag != "Y",
+ ).first()
+
+ if not skill:
+ raise ValueError(f"Skill not found: {skill_id}")
+
+ row_values = _build_skill_update_values(skill_data, updated_by)
+
+ if "name" in skill_data:
+ new_name = str(skill_data["name"] or "").strip()
+ if not new_name:
+ raise ValueError("Skill name is required")
+ if new_name != skill.skill_name:
+ duplicate = session.query(SkillInfo).filter(
+ SkillInfo.skill_name == new_name,
+ SkillInfo.tenant_id == tenant_id,
+ SkillInfo.skill_id != skill_id,
+ SkillInfo.delete_flag != "Y",
+ ).first()
+ if duplicate:
+ raise ValueError(f"Skill '{new_name}' already exists")
+ row_values["skill_name"] = new_name
+ session.execute(
+ sa_update(SkillInfo)
+ .where(
+ SkillInfo.skill_id == skill_id,
+ SkillInfo.tenant_id == tenant_id,
+ SkillInfo.delete_flag != "Y",
+ )
+ .values(**row_values)
+ )
+
+ if "tool_ids" in skill_data:
+ _replace_skill_tool_relations(
+ session,
+ skill_id,
+ skill_data["tool_ids"],
+ )
+
+ session.commit()
+
+ refreshed = session.query(SkillInfo).filter(
+ SkillInfo.skill_id == skill_id,
+ SkillInfo.tenant_id == tenant_id,
+ SkillInfo.delete_flag != "Y",
+ ).first()
+ if not refreshed:
+ raise ValueError(f"Skill not found after update: {skill_id}")
+
+ result = _to_dict(refreshed)
+ result["tool_ids"] = skill_data.get(
+ "tool_ids",
+ _get_tool_ids(session, skill_id),
+ )
+ return result
+
+
def delete_skill(skill_name: str, tenant_id: str, updated_by: Optional[str] = None) -> bool:
"""Soft delete a skill for a tenant (mark as deleted).
@@ -600,8 +688,10 @@ def upsert_scanned_skills(skills: List[Dict[str, Any]], user_id: str, tenant_id:
existing.skill_description = skill_data.get("description", "")
existing.skill_tags = skill_data.get("tags", [])
existing.skill_content = skill_data.get("content", "")
- existing.config_schemas = _params_value_for_db(skill_data.get("config_schemas"))
- existing.config_values = _params_value_for_db(skill_data.get("config_values"))
+ existing.config_schemas = _params_value_for_db(
+ skill_data.get("config_schemas"))
+ existing.config_values = _params_value_for_db(
+ skill_data.get("config_values"))
existing.updated_by = user_id
else:
new_skill = SkillInfo(
@@ -610,8 +700,10 @@ def upsert_scanned_skills(skills: List[Dict[str, Any]], user_id: str, tenant_id:
skill_description=skill_data.get("description", ""),
skill_tags=skill_data.get("tags", []),
skill_content=skill_data.get("content", ""),
- config_schemas=_params_value_for_db(skill_data.get("config_schemas")),
- config_values=_params_value_for_db(skill_data.get("config_values")),
+ config_schemas=_params_value_for_db(
+ skill_data.get("config_schemas")),
+ config_values=_params_value_for_db(
+ skill_data.get("config_values")),
source=skill_data.get("source", "official"),
created_by=user_id,
updated_by=user_id,
diff --git a/backend/database/skill_repository_db.py b/backend/database/skill_repository_db.py
new file mode 100644
index 000000000..e99997476
--- /dev/null
+++ b/backend/database/skill_repository_db.py
@@ -0,0 +1,333 @@
+import math
+from typing import Any, Collection, Dict, List, Optional
+
+from sqlalchemy import func, or_, update
+
+from consts.agent_repository import STATUS_NOT_SHARED
+from database.client import as_dict, filter_property, get_db_session
+from database.db_models import SkillRepository
+
+_UPDATE_ALLOWED_FIELDS = frozenset({
+ "name",
+ "description",
+ "source",
+ "submitted_by",
+ "category_id",
+ "tags",
+ "icon",
+ "downloads",
+ "skill_info_json",
+ "skill_zip_base64",
+ "status",
+})
+
+
+def insert_skill_repository_record(
+ repository_data: Dict[str, Any],
+ publisher_tenant_id: str,
+ publisher_user_id: str,
+) -> int:
+ """Insert a new skill repository listing record."""
+ with get_db_session() as session:
+ payload = {
+ **repository_data,
+ "publisher_tenant_id": publisher_tenant_id,
+ "publisher_user_id": publisher_user_id,
+ "created_by": publisher_user_id,
+ "updated_by": publisher_user_id,
+ "delete_flag": "N",
+ }
+ if payload.get("status") is None:
+ payload["status"] = STATUS_NOT_SHARED
+
+ new_record = SkillRepository(
+ **filter_property(payload, SkillRepository)
+ )
+ session.add(new_record)
+ session.flush()
+ return int(new_record.skill_repository_id)
+
+
+def get_skill_repository_by_id_and_publisher(
+ repository_id: int,
+ publisher_tenant_id: str,
+) -> Optional[dict]:
+ """Fetch a repository listing scoped to the publisher tenant."""
+ with get_db_session() as session:
+ record = session.query(SkillRepository).filter(
+ SkillRepository.skill_repository_id == repository_id,
+ SkillRepository.publisher_tenant_id == publisher_tenant_id,
+ SkillRepository.delete_flag != "Y",
+ ).first()
+ return as_dict(record) if record else None
+
+
+def get_skill_repository_by_skill_id(
+ skill_id: int,
+ *,
+ publisher_tenant_id: Optional[str] = None,
+) -> Optional[dict]:
+ """Fetch an active repository listing by source skill_id."""
+ with get_db_session() as session:
+ query = session.query(SkillRepository).filter(
+ SkillRepository.skill_id == skill_id,
+ SkillRepository.delete_flag != "Y",
+ )
+ if publisher_tenant_id is not None:
+ query = query.filter(
+ SkillRepository.publisher_tenant_id == publisher_tenant_id,
+ )
+ record = query.first()
+ return as_dict(record) if record else None
+
+
+def _apply_skill_repository_filters(
+ query,
+ *,
+ publisher_tenant_id: str,
+ status: Optional[str],
+ skill_id: Optional[int],
+ category_id: Optional[int],
+ search: Optional[str],
+):
+ query = query.filter(
+ SkillRepository.delete_flag != "Y",
+ SkillRepository.publisher_tenant_id == publisher_tenant_id,
+ )
+ if status:
+ query = query.filter(SkillRepository.status == status)
+ if skill_id is not None:
+ query = query.filter(SkillRepository.skill_id == skill_id)
+ if category_id is not None:
+ query = query.filter(SkillRepository.category_id == category_id)
+
+ keyword = (search or "").strip()
+ if keyword:
+ pattern = f"%{keyword}%"
+ query = query.filter(or_(
+ SkillRepository.name.ilike(pattern),
+ SkillRepository.description.ilike(pattern),
+ SkillRepository.source.ilike(pattern),
+ SkillRepository.submitted_by.ilike(pattern),
+ func.array_to_string(SkillRepository.tags, " ").ilike(pattern),
+ ))
+ return query
+
+
+def list_skill_repository_summaries(
+ publisher_tenant_id: str,
+ *,
+ status: Optional[str] = None,
+ skill_id: Optional[int] = None,
+ category_id: Optional[int] = None,
+ page: int = 1,
+ page_size: int = 10,
+ search: Optional[str] = None,
+ sort_by_update_time: bool = False,
+) -> Dict[str, Any]:
+ """List active repository summaries for a publisher tenant without heavy payloads."""
+ safe_page = max(int(page or 1), 1)
+ safe_page_size = max(int(page_size or 10), 1)
+
+ with get_db_session() as session:
+ query = session.query(
+ SkillRepository.skill_repository_id,
+ SkillRepository.skill_id,
+ SkillRepository.submitted_by,
+ SkillRepository.name,
+ SkillRepository.description,
+ SkillRepository.source,
+ SkillRepository.status,
+ SkillRepository.category_id,
+ SkillRepository.tags,
+ SkillRepository.icon,
+ SkillRepository.downloads,
+ SkillRepository.create_time,
+ )
+ query = _apply_skill_repository_filters(
+ query,
+ publisher_tenant_id=publisher_tenant_id,
+ status=status,
+ skill_id=skill_id,
+ category_id=category_id,
+ search=search,
+ )
+
+ total = query.count()
+ order_by_fields = (
+ (SkillRepository.update_time.desc(),
+ SkillRepository.skill_repository_id.desc())
+ if sort_by_update_time
+ else (SkillRepository.skill_repository_id.desc(),)
+ )
+ rows = (
+ query.order_by(*order_by_fields)
+ .offset((safe_page - 1) * safe_page_size)
+ .limit(safe_page_size)
+ .all()
+ )
+
+ items = [
+ {
+ "skill_repository_id": row.skill_repository_id,
+ "skill_id": row.skill_id,
+ "submitted_by": row.submitted_by,
+ "name": row.name,
+ "description": row.description,
+ "source": row.source,
+ "status": row.status,
+ "category_id": row.category_id,
+ "tags": row.tags or [],
+ "icon": row.icon,
+ "downloads": row.downloads or 0,
+ "created_at": row.create_time.isoformat() if row.create_time else None,
+ }
+ for row in rows
+ ]
+ return {
+ "items": items,
+ "pagination": {
+ "page": safe_page,
+ "page_size": safe_page_size,
+ "total": total,
+ "total_pages": math.ceil(total / safe_page_size) if total else 0,
+ },
+ }
+
+
+def update_skill_repository_by_id(
+ *,
+ repository_id: int,
+ publisher_tenant_id: str,
+ user_id: str,
+ updates: Dict[str, Any],
+) -> int:
+ """Update a repository listing owned by the publisher tenant. Returns affected row count."""
+ update_fields = {
+ key: value
+ for key, value in updates.items()
+ if key in _UPDATE_ALLOWED_FIELDS
+ }
+ if not update_fields:
+ return 0
+
+ update_fields["updated_by"] = user_id
+
+ with get_db_session() as session:
+ result = session.execute(
+ update(SkillRepository)
+ .where(
+ SkillRepository.skill_repository_id == repository_id,
+ SkillRepository.publisher_tenant_id == publisher_tenant_id,
+ SkillRepository.delete_flag != "Y",
+ )
+ .values(**update_fields)
+ )
+ return int(result.rowcount or 0)
+
+
+def update_skill_repository_status_by_id(
+ *,
+ repository_id: int,
+ status: str,
+ user_id: str,
+ filter_publisher_tenant_id: Optional[str] = None,
+ publisher_tenant_id: Optional[str] = None,
+ publisher_user_id: Optional[str] = None,
+ submitted_by: Optional[str] = None,
+) -> int:
+ """Update repository listing status by primary key. Returns affected row count."""
+ update_values: Dict[str, Any] = {
+ "status": status,
+ "updated_by": user_id,
+ }
+ if publisher_tenant_id is not None:
+ update_values["publisher_tenant_id"] = publisher_tenant_id
+ if publisher_user_id is not None:
+ update_values["publisher_user_id"] = publisher_user_id
+ if submitted_by is not None:
+ update_values["submitted_by"] = submitted_by
+
+ with get_db_session() as session:
+ where_clauses = [
+ SkillRepository.skill_repository_id == repository_id,
+ SkillRepository.delete_flag != "Y",
+ ]
+ if filter_publisher_tenant_id is not None:
+ where_clauses.append(
+ SkillRepository.publisher_tenant_id == filter_publisher_tenant_id
+ )
+ result = session.execute(
+ update(SkillRepository)
+ .where(*where_clauses)
+ .values(**update_values)
+ )
+ return int(result.rowcount or 0)
+
+
+def increment_skill_repository_downloads(
+ *,
+ repository_id: int,
+ user_id: Optional[str] = None,
+ increment: int = 1,
+) -> int:
+ """Increment install count for a repository listing. Returns affected row count."""
+ update_values: Dict[str, Any] = {
+ "downloads": func.coalesce(SkillRepository.downloads, 0) + increment,
+ }
+ if user_id is not None:
+ update_values["updated_by"] = user_id
+
+ with get_db_session() as session:
+ result = session.execute(
+ update(SkillRepository)
+ .where(
+ SkillRepository.skill_repository_id == repository_id,
+ SkillRepository.delete_flag != "Y",
+ )
+ .values(**update_values)
+ )
+ return int(result.rowcount or 0)
+
+
+def list_skill_repository_by_skill_ids(
+ skill_ids: List[int],
+ *,
+ statuses: Collection[str],
+ publisher_tenant_id: str,
+) -> List[dict]:
+ """List repository rows for the given skills, scoped to publisher tenant and statuses."""
+ if not skill_ids:
+ return []
+
+ status_list = list(statuses)
+ with get_db_session() as session:
+ rows = (
+ session.query(
+ SkillRepository.skill_repository_id,
+ SkillRepository.skill_id,
+ SkillRepository.status,
+ SkillRepository.create_time,
+ )
+ .filter(
+ SkillRepository.delete_flag != "Y",
+ SkillRepository.publisher_tenant_id == publisher_tenant_id,
+ SkillRepository.skill_id.in_(skill_ids),
+ SkillRepository.status.in_(status_list),
+ )
+ .order_by(
+ SkillRepository.skill_id,
+ SkillRepository.create_time.desc(),
+ )
+ .all()
+ )
+
+ return [
+ {
+ "skill_repository_id": row.skill_repository_id,
+ "skill_id": row.skill_id,
+ "status": row.status,
+ "create_time": row.create_time,
+ }
+ for row in rows
+ ]
diff --git a/backend/services/skill_repository_service.py b/backend/services/skill_repository_service.py
new file mode 100644
index 000000000..28f181c75
--- /dev/null
+++ b/backend/services/skill_repository_service.py
@@ -0,0 +1,884 @@
+import base64
+import logging
+import math
+import re
+from typing import Any, Dict, FrozenSet, List, Optional, Tuple
+
+from consts.agent_repository import (
+ OWNERSHIP_ALL,
+ OWNERSHIP_CREATED,
+ OWNERSHIP_OTHERS,
+ STATUS_NOT_SHARED,
+ STATUS_PENDING_REVIEW,
+ STATUS_REJECTED,
+ STATUS_SHARED,
+ VALID_OWNERSHIP_FILTERS,
+ VALID_REPOSITORY_STATUSES,
+)
+from consts.const import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ
+from consts.exceptions import ForbiddenError, SkillDuplicateError, SkillException
+from database.skill_repository_db import (
+ get_skill_repository_by_id_and_publisher,
+ get_skill_repository_by_skill_id,
+ increment_skill_repository_downloads,
+ insert_skill_repository_record,
+ list_skill_repository_by_skill_ids,
+ list_skill_repository_summaries,
+ update_skill_repository_by_id,
+ update_skill_repository_status_by_id,
+)
+from database.skill_db import get_skill_by_name
+from database.user_tenant_db import get_user_tenant_by_user_id
+from services.skill_service import SkillService
+
+logger = logging.getLogger("skill_repository_service")
+_REPOSITORY_LISTING_NOT_FOUND = "Repository listing not found"
+
+_MY_SKILL_REPOSITORY_STATUSES = frozenset({
+ STATUS_SHARED,
+ STATUS_PENDING_REVIEW,
+ STATUS_REJECTED,
+})
+
+_SU_STATUS_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({
+ (STATUS_PENDING_REVIEW, STATUS_REJECTED),
+ (STATUS_PENDING_REVIEW, STATUS_SHARED),
+ (STATUS_SHARED, STATUS_NOT_SHARED),
+})
+
+_PUBLISHER_STATUS_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({
+ (STATUS_NOT_SHARED, STATUS_PENDING_REVIEW),
+ (STATUS_REJECTED, STATUS_PENDING_REVIEW),
+ (STATUS_PENDING_REVIEW, STATUS_NOT_SHARED),
+ (STATUS_REJECTED, STATUS_NOT_SHARED),
+ (STATUS_SHARED, STATUS_NOT_SHARED),
+})
+
+_PUBLISHER_RESUBMIT_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({
+ (STATUS_NOT_SHARED, STATUS_PENDING_REVIEW),
+ (STATUS_REJECTED, STATUS_PENDING_REVIEW),
+})
+
+_ADMIN_REVIEW_STATUS_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({
+ (STATUS_PENDING_REVIEW, STATUS_REJECTED),
+ (STATUS_PENDING_REVIEW, STATUS_SHARED),
+})
+
+_MAX_LISTING_TAGS = 5
+_MAX_LISTING_TAG_LENGTH = 20
+_MAX_LISTING_ICON_LENGTH = 32
+_MAX_COPY_NAME_LENGTH = 100
+_UPDATE_SNAPSHOT_FIELDS = (
+ "name",
+ "description",
+ "source",
+ "submitted_by",
+ "category_id",
+ "tags",
+ "icon",
+ "downloads",
+ "skill_info_json",
+ "skill_zip_base64",
+ "status",
+)
+
+
+def _serialize_created_at(create_time: Any) -> Optional[str]:
+ """Serialize DB create_time to an ISO string for API consumers."""
+ if create_time is None:
+ return None
+ if hasattr(create_time, "isoformat"):
+ return create_time.isoformat()
+ return str(create_time)
+
+
+def _to_summary_item(record: Dict[str, Any]) -> Dict[str, Any]:
+ """Map a DB record to a lightweight skill marketplace summary item."""
+ return {
+ "id": record.get("skill_repository_id"),
+ "skill_repository_id": record.get("skill_repository_id"),
+ "skill_id": record.get("skill_id"),
+ "submitted_by": record.get("submitted_by"),
+ "name": record.get("name"),
+ "description": record.get("description"),
+ "source": record.get("source"),
+ "status": record.get("status"),
+ "category_id": record.get("category_id"),
+ "tags": record.get("tags") or [],
+ "icon": record.get("icon"),
+ "downloads": record.get("downloads") or 0,
+ "created_at": record.get("created_at") or _serialize_created_at(record.get("create_time")),
+ "updated_at": record.get("updated_at") or _serialize_created_at(record.get("update_time")),
+ }
+
+
+def _to_detail_item(
+ record: Dict[str, Any],
+ *,
+ is_updated: Optional[bool] = None,
+) -> Dict[str, Any]:
+ """Map a DB record to a skill marketplace detail payload."""
+ snapshot = _as_dict(record.get("skill_info_json"))
+ detail = {
+ "skill_repository_id": record.get("skill_repository_id"),
+ "skill_id": record.get("skill_id"),
+ "name": record.get("name"),
+ "description": record.get("description"),
+ "source": record.get("source"),
+ "submitted_by": record.get("submitted_by"),
+ "icon": record.get("icon"),
+ "status": record.get("status"),
+ "category_id": record.get("category_id"),
+ "tags": record.get("tags") or _as_list(snapshot.get("tags")),
+ "downloads": record.get("downloads") or 0,
+ "created_at": _serialize_created_at(record.get("create_time")),
+ "updated_at": _serialize_created_at(record.get("update_time")),
+ "content": snapshot.get("content"),
+ "config_schemas": _as_dict(snapshot.get("config_schemas")),
+ "config_values": _as_dict(snapshot.get("config_values")),
+ "tool_ids": _as_list(snapshot.get("tool_ids")),
+ }
+ if is_updated is not None:
+ detail["is_updated"] = is_updated
+ return detail
+
+
+def _as_list(value: Any) -> List[Any]:
+ """Return list values safely for JSON snapshot fields."""
+ return value if isinstance(value, list) else []
+
+
+def _as_dict(value: Any) -> Dict[str, Any]:
+ """Return dict values safely for JSON snapshot fields."""
+ return value if isinstance(value, dict) else {}
+
+
+def _to_repository_info_item(record: Dict[str, Any]) -> Dict[str, Any]:
+ """Map a repository DB row to a my-skills repository_info entry."""
+ return {
+ "skill_repository_id": record.get("skill_repository_id"),
+ "status": record.get("status"),
+ "create_time": _serialize_created_at(record.get("create_time")),
+ }
+
+
+def _matches_ownership(skill: Dict[str, Any], user_id: str, ownership_filter: str) -> bool:
+ """Return whether a skill belongs to the requested ownership bucket."""
+ created_by = skill.get("created_by")
+ if ownership_filter in (OWNERSHIP_ALL, OWNERSHIP_CREATED):
+ return created_by == user_id
+ if ownership_filter == OWNERSHIP_OTHERS:
+ return False
+ return created_by == user_id
+
+
+def _matches_search(skill: Dict[str, Any], search: Optional[str]) -> bool:
+ """Match mine-tab search against skill display fields and tags."""
+ keyword = (search or "").strip().lower()
+ if not keyword:
+ return True
+
+ haystack = [
+ skill.get("name"),
+ skill.get("description"),
+ skill.get("source"),
+ skill.get("created_by"),
+ ]
+ haystack.extend(_as_list(skill.get("tags")))
+ return any(keyword in str(value or "").lower() for value in haystack)
+
+
+def _count_skills_by_ownership(skills: List[Dict[str, Any]], user_id: str) -> Dict[str, int]:
+ """Count editable skills in each ownership bucket."""
+ created = sum(1 for skill in skills if skill.get("created_by") == user_id)
+ return {
+ OWNERSHIP_ALL: created,
+ OWNERSHIP_CREATED: created,
+ OWNERSHIP_OTHERS: 0,
+ }
+
+
+def _paginate_mine_skills_with_optional_padding(
+ filtered_skills: List[Dict[str, Any]],
+ page: int,
+ page_size: int,
+ include_padding: bool,
+) -> Tuple[List[Dict[str, Any]], int]:
+ """Paginate mine skills with an optional create-skill placeholder at virtual index 0."""
+ skill_count = len(filtered_skills)
+ total = skill_count + 1 if include_padding else skill_count
+ if total == 0:
+ return [], 0
+
+ offset = (page - 1) * page_size
+ paged_entries: List[Dict[str, Any]] = []
+ for slot in range(offset, min(offset + page_size, total)):
+ if include_padding and slot == 0:
+ paged_entries.append({"new_skill_padding": True})
+ else:
+ skill_index = slot - 1 if include_padding else slot
+ paged_entries.append(filtered_skills[skill_index])
+ return paged_entries, total
+
+
+def _get_user_role(user_id: str) -> str:
+ """Resolve user role from user_tenant_t; default to USER when unset."""
+ user_tenant = get_user_tenant_by_user_id(user_id)
+ if not user_tenant:
+ return "USER"
+ return str(user_tenant.get("user_role") or "USER")
+
+
+def _resolve_mine_skill_permission(
+ *,
+ skill: Dict[str, Any],
+ user_id: str,
+ user_role: str,
+) -> str:
+ """Resolve list-item permission for skill repository mine view."""
+ if user_role in CAN_EDIT_ALL_USER_ROLES:
+ return PERMISSION_EDIT
+ return PERMISSION_EDIT if skill.get("created_by") == user_id else PERMISSION_READ
+
+
+def _resolve_submitter_email(user_id: str) -> Optional[str]:
+ """Resolve submitter email from user_tenant_t for pending_review listings."""
+ user_tenant = get_user_tenant_by_user_id(user_id) or {}
+ email = str(user_tenant.get("user_email") or "").strip()
+ return email or None
+
+
+def _validate_create_listing_permission(
+ *,
+ user_id: str,
+ skill_info: Dict[str, Any],
+) -> None:
+ """Only ADMIN, or DEV who created the skill, may share to marketplace."""
+ user_role = _get_user_role(user_id)
+ if user_role == "ADMIN":
+ return
+ if user_role == "DEV" and skill_info.get("created_by") == user_id:
+ return
+ raise ForbiddenError(
+ f"User role {user_role} not authorized to create repository listing"
+ )
+
+
+def _normalize_listing_tags(tags: Any) -> List[str]:
+ """Trim, deduplicate, and validate marketplace listing tags."""
+ if tags is None:
+ return []
+ if not isinstance(tags, list):
+ raise ValueError("tags must be a list of strings")
+
+ normalized: List[str] = []
+ seen: set[str] = set()
+ for raw_tag in tags:
+ if not isinstance(raw_tag, str):
+ raise ValueError("tags must be a list of strings")
+ tag = raw_tag.strip()
+ if not tag:
+ continue
+ if len(tag) > _MAX_LISTING_TAG_LENGTH:
+ raise ValueError(
+ f"Each tag must be at most {_MAX_LISTING_TAG_LENGTH} characters"
+ )
+ if tag in seen:
+ continue
+ seen.add(tag)
+ normalized.append(tag)
+
+ if len(normalized) > _MAX_LISTING_TAGS:
+ raise ValueError(f"tags must contain at most {_MAX_LISTING_TAGS} items")
+ return normalized
+
+
+def _validate_card_fields(repository_data: Dict[str, Any]) -> None:
+ """Validate marketplace card fields required for listing submission."""
+ icon = repository_data.get("icon") or "skill"
+ if not icon or not isinstance(icon, str) or not icon.strip():
+ raise ValueError("icon is required and must be a non-empty string")
+ if len(icon.strip()) > _MAX_LISTING_ICON_LENGTH:
+ raise ValueError(
+ f"icon must be at most {_MAX_LISTING_ICON_LENGTH} characters"
+ )
+ repository_data["icon"] = icon.strip()
+
+ category_id = repository_data.get("category_id")
+ if category_id is not None and not isinstance(category_id, int):
+ raise ValueError("category_id must be an integer")
+
+ repository_data["tags"] = _normalize_listing_tags(repository_data.get("tags"))
+
+
+def _build_skill_info_json(skill_info: Dict[str, Any]) -> Dict[str, Any]:
+ """Build frozen metadata snapshot for a skill repository listing."""
+ return {
+ "skill_id": skill_info.get("skill_id"),
+ "name": skill_info.get("name"),
+ "description": skill_info.get("description"),
+ "tags": skill_info.get("tags") or [],
+ "content": skill_info.get("content") or "",
+ "config_schemas": skill_info.get("config_schemas"),
+ "config_values": skill_info.get("config_values"),
+ "source": skill_info.get("source"),
+ "tool_ids": skill_info.get("tool_ids") or [],
+ "created_by": skill_info.get("created_by"),
+ }
+
+
+def _export_skill_zip_base64(
+ *,
+ skill_name: str,
+ tenant_id: str,
+) -> str:
+ """Export a skill ZIP payload as base64 for frozen repository installation."""
+ service = SkillService(tenant_id=tenant_id)
+ exports = service.export_skills_by_names([skill_name], tenant_id=tenant_id)
+ for item in exports:
+ if item.get("skill_name") == skill_name and item.get("skill_zip_base64"):
+ return item["skill_zip_base64"]
+ raise ValueError(f"Failed to export skill ZIP for repository listing: {skill_name}")
+
+
+def _build_repository_data_from_skill(
+ skill_id: int,
+ tenant_id: str,
+ user_id: str,
+ *,
+ card_fields: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Build a repository upsert payload from the current skill snapshot."""
+ service = SkillService(tenant_id=tenant_id)
+ skill_info = service.get_skill_by_id(skill_id, tenant_id=tenant_id)
+ if not skill_info:
+ raise ValueError("Skill not found")
+
+ _validate_create_listing_permission(user_id=user_id, skill_info=skill_info)
+
+ skill_name = str(skill_info.get("name") or "").strip()
+ if not skill_name:
+ raise ValueError("Skill name is required")
+
+ repository_data: Dict[str, Any] = {
+ "skill_id": skill_id,
+ "name": skill_name,
+ "description": skill_info.get("description"),
+ "source": skill_info.get("source"),
+ "submitted_by": _resolve_submitter_email(user_id),
+ "icon": "skill",
+ "tags": skill_info.get("tags") or [],
+ "skill_info_json": _build_skill_info_json(skill_info),
+ "skill_zip_base64": _export_skill_zip_base64(
+ skill_name=skill_name,
+ tenant_id=tenant_id,
+ ),
+ "status": STATUS_PENDING_REVIEW,
+ }
+
+ if card_fields:
+ for key in ("icon", "downloads", "category_id"):
+ if key in card_fields and card_fields[key] is not None:
+ repository_data[key] = card_fields[key]
+ if "tags" in card_fields and card_fields["tags"] is not None:
+ repository_data["tags"] = card_fields["tags"]
+
+ return repository_data
+
+
+def _validate_create_payload(repository_data: Dict[str, Any]) -> None:
+ """Validate required fields before inserting a repository listing."""
+ required_fields = (
+ "skill_id",
+ "name",
+ "skill_info_json",
+ "skill_zip_base64",
+ )
+ missing = [
+ field for field in required_fields
+ if field not in repository_data or repository_data[field] is None
+ ]
+ if missing:
+ raise ValueError(f"Missing required repository fields: {', '.join(missing)}")
+ if not repository_data.get("name"):
+ raise ValueError("name must be a non-empty string")
+ if not isinstance(repository_data.get("skill_info_json"), dict):
+ raise ValueError("skill_info_json must be a JSON object")
+ if not isinstance(repository_data.get("skill_zip_base64"), str):
+ raise ValueError("skill_zip_base64 must be a string")
+
+ _validate_card_fields(repository_data)
+
+
+def create_skill_repository_listing_impl(
+ skill_id: int,
+ tenant_id: str,
+ user_id: str,
+ *,
+ card_fields: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Create or update a repository listing from the current skill snapshot."""
+ repository_data = _build_repository_data_from_skill(
+ skill_id,
+ tenant_id,
+ user_id,
+ card_fields=card_fields,
+ )
+ _validate_create_payload(repository_data)
+
+ existing = get_skill_repository_by_skill_id(
+ skill_id,
+ publisher_tenant_id=tenant_id,
+ )
+ if not existing:
+ repository_id = insert_skill_repository_record(
+ repository_data=repository_data,
+ publisher_tenant_id=tenant_id,
+ publisher_user_id=user_id,
+ )
+ is_updated = False
+ else:
+ repository_id = int(existing["skill_repository_id"])
+ updates = {
+ key: repository_data[key]
+ for key in _UPDATE_SNAPSHOT_FIELDS
+ if key in repository_data
+ }
+ affected = update_skill_repository_by_id(
+ repository_id=repository_id,
+ publisher_tenant_id=tenant_id,
+ user_id=user_id,
+ updates=updates,
+ )
+ if affected == 0:
+ raise ValueError("Failed to update repository listing")
+ is_updated = True
+
+ record = get_skill_repository_by_id_and_publisher(
+ repository_id,
+ tenant_id,
+ )
+ if not record:
+ raise ValueError("Failed to load repository listing after write")
+ return _to_detail_item(record, is_updated=is_updated)
+
+
+def _validate_su_status_transition(
+ transition: Tuple[str, str],
+ current_status: str,
+ new_status: str,
+) -> None:
+ if transition not in _SU_STATUS_TRANSITIONS:
+ raise ValueError(
+ f"Invalid status transition from '{current_status}' to '{new_status}'"
+ )
+
+
+def _validate_publisher_status_transition(
+ *,
+ user_role: str,
+ transition: Tuple[str, str],
+ current_status: str,
+ new_status: str,
+ record: Dict[str, Any],
+ user_id: str,
+ tenant_id: str,
+) -> Optional[Dict[str, str]]:
+ if record.get("publisher_tenant_id") != tenant_id:
+ raise ForbiddenError("Not authorized to update this repository listing")
+ if user_role == "DEV" and record.get("publisher_user_id") != user_id:
+ raise ForbiddenError("Not authorized to update this repository listing")
+ if user_role == "ADMIN" and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS:
+ return None
+ if transition not in _PUBLISHER_STATUS_TRANSITIONS:
+ raise ValueError(
+ f"Invalid status transition from '{current_status}' to '{new_status}'"
+ )
+ if transition in _PUBLISHER_RESUBMIT_TRANSITIONS:
+ return {
+ "publisher_tenant_id": tenant_id,
+ "publisher_user_id": user_id,
+ }
+ return None
+
+
+def _validate_repository_status_transition(
+ *,
+ user_role: str,
+ current_status: str,
+ new_status: str,
+ record: Dict[str, Any],
+ user_id: str,
+ tenant_id: str,
+) -> Optional[Dict[str, str]]:
+ """Validate role, ownership, and allowed status transition."""
+ transition = (current_status, new_status)
+
+ if user_role == "SU":
+ _validate_su_status_transition(transition, current_status, new_status)
+ return None
+
+ if user_role in ("ADMIN", "DEV"):
+ return _validate_publisher_status_transition(
+ user_role=user_role,
+ transition=transition,
+ current_status=current_status,
+ new_status=new_status,
+ record=record,
+ user_id=user_id,
+ tenant_id=tenant_id,
+ )
+
+ raise ForbiddenError(
+ f"User role {user_role} not authorized to update repository status"
+ )
+
+
+def update_skill_repository_status_impl(
+ *,
+ skill_repository_id: int,
+ status: str,
+ user_id: str,
+ tenant_id: str,
+) -> Dict[str, Any]:
+ """Update a skill repository listing status by primary key."""
+ if status not in VALID_REPOSITORY_STATUSES:
+ raise ValueError(
+ f"Invalid status '{status}'; must be one of: "
+ f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}"
+ )
+
+ record = get_skill_repository_by_id_and_publisher(
+ skill_repository_id,
+ tenant_id,
+ )
+ if not record:
+ raise ValueError(_REPOSITORY_LISTING_NOT_FOUND)
+
+ current_status = record.get("status")
+ publisher_updates: Optional[Dict[str, str]] = None
+ submitted_by: Optional[str] = None
+ if current_status != status:
+ user_role = _get_user_role(user_id)
+ publisher_updates = _validate_repository_status_transition(
+ user_role=user_role,
+ current_status=current_status,
+ new_status=status,
+ record=record,
+ user_id=user_id,
+ tenant_id=tenant_id,
+ )
+ if status == STATUS_PENDING_REVIEW:
+ submitted_by = _resolve_submitter_email(user_id)
+
+ rows_affected = update_skill_repository_status_by_id(
+ repository_id=skill_repository_id,
+ status=status,
+ user_id=user_id,
+ filter_publisher_tenant_id=tenant_id,
+ publisher_tenant_id=(
+ publisher_updates["publisher_tenant_id"]
+ if publisher_updates
+ else None
+ ),
+ publisher_user_id=(
+ publisher_updates["publisher_user_id"]
+ if publisher_updates
+ else None
+ ),
+ submitted_by=submitted_by,
+ )
+ if rows_affected == 0:
+ raise ValueError(_REPOSITORY_LISTING_NOT_FOUND)
+
+ updated = get_skill_repository_by_id_and_publisher(
+ skill_repository_id,
+ tenant_id,
+ )
+ if not updated:
+ raise ValueError("Failed to load repository listing after update")
+ return _to_summary_item(updated)
+
+
+def _extract_duplicate_skill_name(error_message: str) -> Optional[str]:
+ """Extract duplicate skill name from existing SkillException messages."""
+ match = re.search(r"Skill '([^']+)' already exists", error_message)
+ if match:
+ return match.group(1)
+ return None
+
+
+def _truncate_copy_base_name(base_name: str, suffix: str) -> str:
+ """Trim a copied skill base name so the final name fits the database limit."""
+ max_base_length = max(_MAX_COPY_NAME_LENGTH - len(suffix), 1)
+ if len(base_name) <= max_base_length:
+ return base_name
+ return base_name[:max_base_length].rstrip() or base_name[:max_base_length]
+
+
+def _generate_available_copy_skill_name(
+ *,
+ base_name: str,
+ tenant_id: str,
+) -> str:
+ """Generate an available skill name for repository copy within the tenant."""
+ normalized_base = (base_name or "Skill").strip() or "Skill"
+ if not get_skill_by_name(normalized_base, tenant_id):
+ return normalized_base
+
+ index = 1
+ while True:
+ suffix = " 副本" if index == 1 else f" 副本 {index}"
+ candidate = f"{_truncate_copy_base_name(normalized_base, suffix)}{suffix}"
+ if not get_skill_by_name(candidate, tenant_id):
+ return candidate
+ index += 1
+
+
+def install_skill_from_repository_impl(
+ *,
+ skill_repository_id: int,
+ tenant_id: str,
+ user_id: str,
+ target_name: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Install a shared skill repository listing into the current tenant."""
+ record = get_skill_repository_by_id_and_publisher(
+ skill_repository_id,
+ tenant_id,
+ )
+ if not record:
+ raise ValueError(_REPOSITORY_LISTING_NOT_FOUND)
+ if record.get("status") != STATUS_SHARED:
+ raise ValueError("Repository listing is not available for install")
+
+ skill_zip_base64 = record.get("skill_zip_base64")
+ if not isinstance(skill_zip_base64, str) or not skill_zip_base64.strip():
+ raise ValueError("Repository listing has no skill ZIP payload")
+
+ try:
+ zip_bytes = base64.b64decode(skill_zip_base64, validate=True)
+ except Exception as exc:
+ raise ValueError("Repository listing has invalid skill ZIP payload") from exc
+
+ copy_skill_name = str(target_name or "").strip()
+ if not copy_skill_name:
+ copy_skill_name = _generate_available_copy_skill_name(
+ base_name=str(record.get("name") or "").strip(),
+ tenant_id=tenant_id,
+ )
+ if not copy_skill_name:
+ raise ValueError("Skill name is required")
+
+ try:
+ created_skill = SkillService(tenant_id=tenant_id).create_skill_from_zip_bytes(
+ zip_bytes=zip_bytes,
+ skill_name=copy_skill_name,
+ source="repository",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ )
+ except SkillException as exc:
+ message = str(exc)
+ if "already exists" in message.lower():
+ duplicate_name = _extract_duplicate_skill_name(message) or copy_skill_name
+ raise SkillDuplicateError([duplicate_name]) from exc
+ raise
+
+ affected = increment_skill_repository_downloads(
+ repository_id=skill_repository_id,
+ user_id=user_id,
+ )
+ if affected == 0:
+ logger.warning(
+ "Failed to increment skill repository downloads after install "
+ "(skill_repository_id=%s)",
+ skill_repository_id,
+ )
+
+ return {
+ "skill_id": created_skill.get("skill_id"),
+ "name": created_skill.get("name"),
+ "description": created_skill.get("description"),
+ "source": created_skill.get("source"),
+ "tags": created_skill.get("tags") or [],
+ }
+
+
+def _list_repository_info_by_skill_id(
+ paged_skills: List[Dict[str, Any]],
+ tenant_id: str,
+) -> Dict[int, List[Dict[str, Any]]]:
+ skill_ids = [
+ int(skill["skill_id"])
+ for skill in paged_skills
+ if skill.get("skill_id") is not None
+ ]
+ if not skill_ids:
+ return {}
+
+ repository_by_skill_id: Dict[int, List[Dict[str, Any]]] = {}
+ repository_records = list_skill_repository_by_skill_ids(
+ skill_ids,
+ statuses=_MY_SKILL_REPOSITORY_STATUSES,
+ publisher_tenant_id=tenant_id,
+ )
+ for record in repository_records:
+ skill_id = record.get("skill_id")
+ if skill_id is None:
+ continue
+ repository_by_skill_id.setdefault(int(skill_id), []).append(
+ _to_repository_info_item(record)
+ )
+ return repository_by_skill_id
+
+
+def _to_mine_skill_item(
+ skill: Dict[str, Any],
+ *,
+ user_id: str,
+ user_role: str,
+ repository_by_skill_id: Dict[int, List[Dict[str, Any]]],
+) -> Dict[str, Any]:
+ if skill.get("new_skill_padding"):
+ return {"new_skill_padding": True}
+
+ skill_id = skill.get("skill_id")
+ repository_info = (
+ repository_by_skill_id.get(int(skill_id), [])
+ if skill_id is not None
+ else []
+ )
+ return {
+ "skill_id": skill_id,
+ "name": skill.get("name"),
+ "description": skill.get("description"),
+ "source": skill.get("source"),
+ "tags": skill.get("tags") or [],
+ "created_by": skill.get("created_by"),
+ "created_at": skill.get("create_time"),
+ "updated_at": skill.get("update_time"),
+ "permission": _resolve_mine_skill_permission(
+ skill=skill,
+ user_id=user_id,
+ user_role=user_role,
+ ),
+ "repository_info": repository_info,
+ }
+
+
+def list_my_editable_skills_impl(
+ tenant_id: str,
+ user_id: str,
+ ownership: str = OWNERSHIP_ALL,
+ *,
+ page: int = 1,
+ page_size: int = 10,
+ search: Optional[str] = None,
+ new_skill_padding: bool = False,
+) -> Dict[str, Any]:
+ """List editable skills for the current user with repository listing info."""
+ normalized_ownership = (ownership or OWNERSHIP_ALL).strip().lower()
+ if normalized_ownership not in VALID_OWNERSHIP_FILTERS:
+ raise ValueError(
+ f"Invalid ownership filter: {ownership}. "
+ f"Allowed values: {', '.join(sorted(VALID_OWNERSHIP_FILTERS))}."
+ )
+
+ safe_page = max(int(page or 1), 1)
+ safe_page_size = max(int(page_size or 10), 1)
+
+ user_role = _get_user_role(user_id)
+ skills = SkillService(tenant_id=tenant_id).list_skills(tenant_id=tenant_id)
+ counts = _count_skills_by_ownership(skills, user_id)
+
+ filtered_skills = [
+ skill for skill in skills
+ if _matches_ownership(skill, user_id, normalized_ownership)
+ and _matches_search(skill, search)
+ ]
+ include_padding = (
+ new_skill_padding
+ and normalized_ownership == OWNERSHIP_ALL
+ and not (search and search.strip())
+ )
+ paged_skills, total = _paginate_mine_skills_with_optional_padding(
+ filtered_skills,
+ page=safe_page,
+ page_size=safe_page_size,
+ include_padding=include_padding,
+ )
+
+ repository_by_skill_id = _list_repository_info_by_skill_id(
+ paged_skills,
+ tenant_id,
+ )
+ items = [
+ _to_mine_skill_item(
+ skill,
+ user_id=user_id,
+ user_role=user_role,
+ repository_by_skill_id=repository_by_skill_id,
+ )
+ for skill in paged_skills
+ ]
+
+ return {
+ "items": items,
+ "counts": counts,
+ "pagination": {
+ "page": safe_page,
+ "page_size": safe_page_size,
+ "total": total,
+ "total_pages": math.ceil(total / safe_page_size) if total else 0,
+ },
+ }
+
+
+def list_skill_repository_listings_impl(
+ tenant_id: str,
+ *,
+ status: Optional[str] = None,
+ skill_id: Optional[int] = None,
+ category_id: Optional[int] = None,
+ page: int = 1,
+ page_size: int = 10,
+ search: Optional[str] = None,
+ sort_by_update_time: bool = False,
+) -> Dict[str, Any]:
+ """List skill repository listings for the caller tenant with optional filters."""
+ if status is not None and status not in VALID_REPOSITORY_STATUSES:
+ raise ValueError(
+ f"Invalid status '{status}'; must be one of: "
+ f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}"
+ )
+
+ result = list_skill_repository_summaries(
+ publisher_tenant_id=tenant_id,
+ status=status,
+ skill_id=skill_id,
+ category_id=category_id,
+ page=page,
+ page_size=page_size,
+ search=search,
+ sort_by_update_time=sort_by_update_time,
+ )
+ return {
+ "items": [_to_summary_item(record) for record in result.get("items", [])],
+ "pagination": result.get("pagination"),
+ }
+
+
+def get_skill_repository_listing_detail_impl(
+ skill_repository_id: int,
+ tenant_id: str,
+) -> Dict[str, Any]:
+ """Load a skill repository listing and return a frozen detail payload for the UI."""
+ record = get_skill_repository_by_id_and_publisher(
+ skill_repository_id,
+ tenant_id,
+ )
+ if not record:
+ raise ValueError(_REPOSITORY_LISTING_NOT_FOUND)
+
+ return _to_detail_item(record)
diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py
index f5b7d1c7c..e32cc532a 100644
--- a/backend/services/skill_service.py
+++ b/backend/services/skill_service.py
@@ -22,7 +22,7 @@
from nexent.core.utils.observer import MessageObserver
from nexent.core.agents.agent_model import ModelConfig
from consts.const import CONTAINER_SKILLS_PATH, OFFICIAL_SKILLS_ZIP_PATH, ROOT_DIR
-from consts.exceptions import SkillException
+from consts.exceptions import ForbiddenError, SkillException
from database import skill_db
from agents.skill_creation_agent import create_skill_from_request
from utils.prompt_template_utils import get_skill_creation_simple_prompt_template
@@ -760,12 +760,61 @@ def _get_skill_inputs_from_zip(
def _local_skill_config_yaml_path(skill_name: str, local_skills_dir: str) -> str:
"""Absolute path to //config/config.yaml."""
- return os.path.join(local_skills_dir, skill_name, "config", "config.yaml")
+ return _resolve_local_skill_path(
+ local_skills_dir,
+ skill_name,
+ "config",
+ "config.yaml",
+ )
def _local_skill_schema_yaml_path(skill_name: str, local_skills_dir: str) -> str:
"""Absolute path to //config/schema.yaml."""
- return os.path.join(local_skills_dir, skill_name, "config", "schema.yaml")
+ return _resolve_local_skill_path(
+ local_skills_dir,
+ skill_name,
+ "config",
+ "schema.yaml",
+ )
+
+
+def _resolve_local_skill_path(
+ local_skills_dir: str,
+ skill_name: str,
+ *parts: str,
+) -> str:
+ """Resolve a path below the configured skills root and one skill directory."""
+ name = str(skill_name or "").strip()
+ if (
+ not name
+ or name in {".", ".."}
+ or "/" in name
+ or "\\" in name
+ or "\x00" in name
+ or os.path.basename(name) != name
+ ):
+ raise SkillException("Invalid skill name for local file access")
+
+ allowed_root = os.path.realpath(CONTAINER_SKILLS_PATH)
+ local_root = os.path.realpath(local_skills_dir)
+ if (
+ local_root != allowed_root
+ and not local_root.startswith(allowed_root + os.sep)
+ ):
+ raise SkillException("Unsafe local skills directory")
+
+ candidate = os.path.realpath(os.path.join(local_root, name, *parts))
+ if (
+ candidate != allowed_root
+ and not candidate.startswith(allowed_root + os.sep)
+ ):
+ raise SkillException("Unsafe local skill path")
+ if (
+ candidate != local_root
+ and not candidate.startswith(local_root + os.sep)
+ ):
+ raise SkillException("Unsafe local skill path")
+ return candidate
def _write_skill_params_to_local_config_yaml(
@@ -778,9 +827,9 @@ def _write_skill_params_to_local_config_yaml(
if not local_skills_dir:
return
- config_dir = os.path.join(local_skills_dir, skill_name, "config")
- os.makedirs(config_dir, exist_ok=True)
path = _local_skill_config_yaml_path(skill_name, local_skills_dir)
+ config_dir = os.path.dirname(path)
+ os.makedirs(config_dir, exist_ok=True)
text = params_dict_to_roundtrip_yaml_text(params)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
@@ -822,7 +871,12 @@ def __init__(self, skill_manager: Optional[SkillManager] = None, tenant_id: Opti
def _resolve_local_skills_dir_for_overlay(self) -> Optional[str]:
"""Directory where skill folders live: ``SKILLS_PATH``, else ``ROOT_DIR/skills`` if present."""
- d = self.skill_manager.local_skills_dir or CONTAINER_SKILLS_PATH
+ manager_dir = getattr(self.skill_manager, "local_skills_dir", None)
+ d = (
+ manager_dir
+ if isinstance(manager_dir, str)
+ else CONTAINER_SKILLS_PATH
+ )
if d:
return str(d).rstrip(os.sep) or None
if ROOT_DIR:
@@ -970,8 +1024,10 @@ def create_skill(
# Check if skill directory already exists locally
resolved = self._resolve_local_skills_dir_for_overlay()
- if resolved and os.path.exists(os.path.join(resolved, skill_name)):
- raise SkillException(f"Skill '{skill_name}' already exists locally")
+ if resolved:
+ local_skill_dir = _resolve_local_skill_path(resolved, skill_name)
+ if os.path.exists(local_skill_dir):
+ raise SkillException(f"Skill '{skill_name}' already exists locally")
# Set created_by and updated_by if user_id is provided
if user_id:
@@ -1013,7 +1069,7 @@ def create_skill_from_file(
file_content: Union[bytes, str, io.BytesIO],
skill_name: Optional[str] = None,
file_type: str = "auto",
- source: str = "自定义",
+ source: str = "custom",
tenant_id: Optional[str] = None,
user_id: Optional[str] = None
) -> Dict[str, Any]:
@@ -1027,7 +1083,7 @@ def create_skill_from_file(
file_content: File content as bytes, string, or BytesIO
skill_name: Optional skill name (extracted from ZIP if not provided)
file_type: File type hint - "md", "zip", or "auto" (detect)
- source: Source identifier for the skill (e.g., "自定义", "官方", "导入")
+ source: Source identifier for the skill (e.g., "custom", "official", "repository")
tenant_id: Tenant ID for skill isolation. Uses instance tenant_id if not provided.
user_id: User ID of the creator
@@ -1058,7 +1114,7 @@ def _create_skill_from_md(
self,
content_bytes: bytes,
skill_name: Optional[str] = None,
- source: str = "自定义",
+ source: str = "custom",
user_id: Optional[str] = None,
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
@@ -1073,6 +1129,9 @@ def _create_skill_from_md(
name = skill_name or skill_data.get("name")
if not name:
raise SkillException("Skill name is required")
+ local_dir = self._resolve_local_skills_dir_for_overlay()
+ if local_dir:
+ _resolve_local_skill_path(local_dir, name)
# Check if skill already exists in database
existing = skill_db.get_skill_by_name(name, tenant_id)
@@ -1113,7 +1172,7 @@ def _create_skill_from_zip(
self,
zip_bytes: bytes,
skill_name: Optional[str] = None,
- source: str = "自定义",
+ source: str = "custom",
user_id: Optional[str] = None,
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
@@ -1168,6 +1227,9 @@ def _create_skill_from_zip(
name = skill_name or detected_skill_name
if not name:
raise SkillException("Skill name is required")
+ local_dir = self._resolve_local_skills_dir_for_overlay()
+ if local_dir:
+ _resolve_local_skill_path(local_dir, name)
# Check if skill already exists in database
existing = skill_db.get_skill_by_name(name, tenant_id)
@@ -1615,6 +1677,110 @@ def update_skill(
logger.error(f"Error updating skill {skill_name}: {e}")
raise SkillException(f"Failed to update skill: {str(e)}") from e
+ def update_skill_by_id(
+ self,
+ skill_id: int,
+ skill_data: Dict[str, Any],
+ tenant_id: Optional[str] = None,
+ user_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """Update an existing skill by ID for a tenant."""
+ effective_tenant_id = tenant_id or self.tenant_id
+ if not effective_tenant_id:
+ raise SkillException("tenant_id is required")
+ try:
+ existing = skill_db.get_skill_by_id(skill_id, effective_tenant_id)
+ if not existing:
+ raise SkillException(f"Skill not found: {skill_id}")
+ if not user_id or existing.get("created_by") != user_id:
+ raise ForbiddenError("Not authorized to update this skill")
+
+ local_dir = self._resolve_local_skills_dir_for_overlay()
+ if local_dir and "name" in skill_data:
+ _resolve_local_skill_path(
+ local_dir,
+ str(skill_data["name"] or ""),
+ )
+
+ result = skill_db.update_skill_by_id(
+ skill_id,
+ skill_data,
+ effective_tenant_id,
+ updated_by=user_id or None,
+ )
+
+ if not local_dir:
+ return self._enrich_configs_from_yaml(result)
+
+ persisted_skill_id = int(existing["skill_id"])
+ skill_id_directory = _resolve_local_skill_path(
+ local_dir,
+ f"skill_{persisted_skill_id}",
+ )
+ local_skill_name = (
+ f"skill_{persisted_skill_id}"
+ if os.path.isdir(skill_id_directory)
+ else str(result.get("name") or existing.get("name") or "")
+ )
+ if not local_skill_name:
+ return self._enrich_configs_from_yaml(result)
+
+ if local_dir and "config_values" in skill_data:
+ try:
+ raw_config_values = skill_data["config_values"]
+ if raw_config_values is None:
+ _remove_local_skill_config_yaml(local_skill_name, local_dir)
+ else:
+ _write_skill_params_to_local_config_yaml(
+ local_skill_name,
+ _params_dict_to_storable(raw_config_values),
+ local_dir,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Local config/config.yaml sync failed after skill ID update for %s: %s",
+ skill_id,
+ exc,
+ )
+
+ if not local_dir:
+ return self._enrich_configs_from_yaml(result)
+
+ try:
+ allowed_tools = skill_db.get_tool_names_by_skill_name(
+ str(existing.get("name") or ""),
+ effective_tenant_id,
+ )
+ local_skill_dict = {
+ "name": local_skill_name,
+ "description": skill_data.get("description", existing.get("description", "")),
+ "content": skill_data.get("content", existing.get("content", "")),
+ "tags": skill_data.get("tags", existing.get("tags", [])),
+ "allowed-tools": allowed_tools,
+ "files": skill_data.get("files", []),
+ }
+ self.skill_manager.save_skill(local_skill_dict)
+ previous_name = str(existing.get("name") or "").strip()
+ if (
+ local_skill_name != f"skill_{skill_id}"
+ and previous_name
+ and previous_name != local_skill_name
+ ):
+ self.skill_manager.delete_skill(previous_name)
+ except Exception as exc:
+ logger.warning(
+ "Local SKILL.md sync failed after DB update for skill ID %s: %s",
+ skill_id,
+ exc,
+ )
+
+ return self._enrich_configs_from_yaml(result)
+ except (ForbiddenError, SkillException):
+ raise
+ except Exception as e:
+ logger.exception("Error updating skill by ID %s", skill_id)
+ raise SkillException(f"Failed to update skill: {str(e)}") from e
+
def delete_skill(
self,
skill_name: str,
@@ -2078,7 +2244,6 @@ def create_skill_from_zip_bytes(
result = skill_db.create_skill(skill_dict, tenant_id)
self.skill_manager.save_skill(skill_dict)
-
self._upload_zip_files(zip_bytes, name, detected_skill_name)
return self._enrich_configs_from_yaml(result)
diff --git a/backend/utils/skill_params_utils.py b/backend/utils/skill_params_utils.py
index 404e16ccb..f40a218c4 100644
--- a/backend/utils/skill_params_utils.py
+++ b/backend/utils/skill_params_utils.py
@@ -16,7 +16,7 @@ def split_string_inline_comment(s: str) -> Tuple[str, Optional[str]]:
idx = s.find(" # ")
if idx == -1:
return s, None
- return s[:idx].rstrip(), s[idx + 3 :].strip() or None
+ return s[:idx].rstrip(), s[idx + 3:].strip() or None
def strip_params_comments_for_db(obj: Any) -> Any:
diff --git a/deploy/sql/migrations/v2.3.0_0629_add_skill_repository_table.sql b/deploy/sql/migrations/v2.3.0_0629_add_skill_repository_table.sql
new file mode 100644
index 000000000..43f7fd8a4
--- /dev/null
+++ b/deploy/sql/migrations/v2.3.0_0629_add_skill_repository_table.sql
@@ -0,0 +1,99 @@
+-- Migration: Add ag_skill_repository_t table
+-- Date: 2026-06-29
+-- Description: Skill marketplace repository for frozen installable skill snapshots.
+
+SET search_path TO nexent;
+
+CREATE SEQUENCE IF NOT EXISTS nexent.ag_skill_repository_t_skill_repository_id_seq;
+
+CREATE TABLE IF NOT EXISTS nexent.ag_skill_repository_t (
+ skill_repository_id BIGINT NOT NULL DEFAULT nextval('nexent.ag_skill_repository_t_skill_repository_id_seq'),
+ publisher_tenant_id VARCHAR(100) NOT NULL,
+ publisher_user_id VARCHAR(100) NOT NULL,
+ skill_id INTEGER NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ source VARCHAR(30),
+ submitted_by VARCHAR(100),
+ category_id INTEGER,
+ tags TEXT[],
+ icon VARCHAR(100),
+ downloads INTEGER DEFAULT 0,
+ skill_info_json JSONB NOT NULL,
+ skill_zip_base64 TEXT 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_skill_repository_t_pkey PRIMARY KEY (skill_repository_id)
+);
+
+ALTER SEQUENCE nexent.ag_skill_repository_t_skill_repository_id_seq
+ OWNED BY nexent.ag_skill_repository_t.skill_repository_id;
+
+ALTER TABLE nexent.ag_skill_repository_t OWNER TO root;
+
+ALTER TABLE nexent.ag_skill_repository_t
+ ADD COLUMN IF NOT EXISTS submitted_by VARCHAR(100),
+ ADD COLUMN IF NOT EXISTS icon VARCHAR(100),
+ ADD COLUMN IF NOT EXISTS downloads INTEGER DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS skill_zip_base64 TEXT;
+
+COMMENT ON TABLE nexent.ag_skill_repository_t IS 'Skill marketplace repository for frozen installable skill snapshots';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.skill_repository_id IS 'Skill repository listing ID, unique primary key';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.publisher_tenant_id IS 'Publisher tenant ID';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.publisher_user_id IS 'Publisher user ID';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.skill_id IS 'Source skill ID from ag_skill_info_t; unique when active (delete_flag = N)';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.name IS 'Skill name for display and search';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.description IS 'Skill description';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.source IS 'Skill source';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.submitted_by IS 'Submitter email when listing enters pending_review';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.category_id IS 'Optional marketplace category ID';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.tags IS 'Marketplace tags';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.icon IS 'Marketplace card icon (emoji or URL)';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.downloads IS 'Marketplace install count for card display';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.skill_info_json IS 'Frozen skill metadata snapshot';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.skill_zip_base64 IS 'Frozen skill ZIP payload encoded as base64';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.status IS 'Listing status: not_shared / pending_review / rejected / shared';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.create_time IS 'Creation time';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.update_time IS 'Update time';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.created_by IS 'Creator ID';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.updated_by IS 'Updater ID';
+COMMENT ON COLUMN nexent.ag_skill_repository_t.delete_flag IS 'Soft delete flag: Y/N';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_repository_skill_active
+ ON nexent.ag_skill_repository_t (skill_id)
+ WHERE delete_flag = 'N';
+
+CREATE INDEX IF NOT EXISTS idx_skill_repository_publisher_delete
+ ON nexent.ag_skill_repository_t (publisher_tenant_id, delete_flag);
+
+CREATE INDEX IF NOT EXISTS idx_skill_repository_status_delete
+ ON nexent.ag_skill_repository_t (status, delete_flag);
+
+CREATE INDEX IF NOT EXISTS idx_skill_repository_name_delete
+ ON nexent.ag_skill_repository_t (name, delete_flag);
+
+CREATE INDEX IF NOT EXISTS idx_skill_repository_tags_gin
+ ON nexent.ag_skill_repository_t USING GIN (tags);
+
+CREATE OR REPLACE FUNCTION update_ag_skill_repository_update_time()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.update_time = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION update_ag_skill_repository_update_time() IS 'Auto-update update_time for ag_skill_repository_t';
+
+DROP TRIGGER IF EXISTS update_ag_skill_repository_update_time_trigger ON nexent.ag_skill_repository_t;
+CREATE TRIGGER update_ag_skill_repository_update_time_trigger
+BEFORE UPDATE ON nexent.ag_skill_repository_t
+FOR EACH ROW
+EXECUTE FUNCTION update_ag_skill_repository_update_time();
+
+COMMENT ON TRIGGER update_ag_skill_repository_update_time_trigger
+ON nexent.ag_skill_repository_t IS 'Trigger to maintain update_time';
diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx
index f8bbda179..d80fbea58 100644
--- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx
+++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useMemo, useRef } from "react";
+import { useState, useEffect, useRef, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
import {
Modal,
@@ -8,12 +8,8 @@ import {
Form,
Input,
Button,
- AutoComplete,
- Select,
message,
Flex,
- Row,
- Col,
Spin,
Tooltip,
} from "antd";
@@ -21,18 +17,18 @@ import {
Upload as UploadIcon,
Send,
Trash2,
- MessagesSquare,
- HardDriveUpload,
+ MessageCircle,
+ Box,
+ Bot,
Loader2,
- Plus,
- X,
- Pencil,
Square,
} from "lucide-react";
-import { extractSkillInfo, extractSkillInfoFromContent } from "@/lib/skillFileUtils";
+import {
+ extractSkillInfo,
+ extractSkillInfoFromContent,
+} from "@/lib/skillFileUtils";
import yaml from "js-yaml";
import {
- MAX_RECENT_SKILLS,
THINKING_STEPS_ZH,
type SkillFormData,
type ChatMessage,
@@ -43,44 +39,93 @@ import {
submitSkillForm,
submitSkillFromFile,
findSkillByName,
- searchSkillsByName as searchSkillsByNameUtil,
createSkillStream,
- clearChatAndTempFile,
stopSkillCreation,
type SkillListItem,
type SkillData,
} from "@/services/skillService";
-import {
- fetchSkillFiles,
- fetchSkillFileContent,
- SkillFilesAccessDeniedError,
- type SkillFileNode,
-} from "@/services/agentConfigService";
+import { fetchSkillById } from "@/services/agentConfigService";
+import type { MyEditableSkillItem } from "@/types/skillRepository";
import { MarkdownRenderer } from "@/components/common/markdownRenderer";
import log from "@/lib/logger";
+import SkillDraftPanel from "./SkillDraftPanel";
const { TextArea } = Input;
interface SkillBuildModalProps {
isOpen: boolean;
onCancel: () => void;
- onSuccess: () => void;
+ onSuccess: () => void | Promise;
+ editingSkill?: MyEditableSkillItem | null;
+ onBeforeEditSave?: (skill: MyEditableSkillItem) => Promise;
+}
+
+interface StreamedFrontmatter {
+ name: string;
+ description: string;
+ tags: string[];
+}
+
+function parseStreamedFrontmatter(content: string): StreamedFrontmatter | null {
+ try {
+ const parsed = yaml.load(content) as Record | null;
+ if (!parsed || typeof parsed !== "object") {
+ return null;
+ }
+ return {
+ name: typeof parsed.name === "string" ? parsed.name.trim() : "",
+ description:
+ typeof parsed.description === "string" ? parsed.description.trim() : "",
+ tags: Array.isArray(parsed.tags)
+ ? parsed.tags.filter((tag): tag is string => typeof tag === "string")
+ : [],
+ };
+ } catch {
+ return null;
+ }
+}
+
+function mergeGeneratedSkillTabs(
+ currentTabs: SkillFileContent[],
+ generatedTabs: SkillFileContent[],
+ skillContent: string
+) {
+ const generatedByPath = new Map(
+ generatedTabs.map((tab) => [tab.path, tab.content])
+ );
+ const currentPaths = new Set(currentTabs.map((tab) => tab.path));
+ const updatedTabs = currentTabs.map((tab) => {
+ if (tab.path === "SKILL.md") {
+ return { ...tab, content: skillContent };
+ }
+ const generatedContent = generatedByPath.get(tab.path);
+ return generatedContent ? { ...tab, content: generatedContent } : tab;
+ });
+ const newTabs = generatedTabs.filter((tab) => !currentPaths.has(tab.path));
+ const finalTabs = [...updatedTabs, ...newTabs].sort((a, b) => {
+ if (a.path === "SKILL.md") return -1;
+ if (b.path === "SKILL.md") return 1;
+ return a.path.localeCompare(b.path);
+ });
+ return { updatedTabs, finalTabs };
}
export default function SkillBuildModal({
isOpen,
onCancel,
onSuccess,
+ editingSkill,
+ onBeforeEditSave,
}: SkillBuildModalProps) {
const { t } = useTranslation("common");
const [form] = Form.useForm();
+ const isEditMode = Boolean(editingSkill);
const [activeTab, setActiveTab] = useState("interactive");
const [isSubmitting, setIsSubmitting] = useState(false);
const [allSkills, setAllSkills] = useState([]);
- const [searchResults, setSearchResults] = useState([]);
- const [selectedSkillName, setSelectedSkillName] = useState("");
const [uploadFile, setUploadFile] = useState(null);
- const [uploadExtractedSkillName, setUploadExtractedSkillName] = useState("");
+ const [uploadExtractedSkillName, setUploadExtractedSkillName] =
+ useState("");
const [uploadExtractingName, setUploadExtractingName] = useState(false);
// Interactive creation state
@@ -99,10 +144,6 @@ export default function SkillBuildModal({
const [activeSkillTab, setActiveSkillTab] = useState("SKILL.md");
const [isStreaming, setIsStreaming] = useState(false);
- // Tab management state
- const [editingTabKey, setEditingTabKey] = useState(null);
- const [editingTabName, setEditingTabName] = useState("");
-
// Summary content for chat bubble
const [summaryContent, setSummaryContent] = useState("");
@@ -120,7 +161,9 @@ export default function SkillBuildModal({
const ref = textareaRefs.current[tabPath] as any;
const textarea = ref?.resizableTextArea?.textArea || ref?.textArea || ref;
if (!textarea) return true;
- return textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 20;
+ return (
+ textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 20
+ );
};
// Update shouldAutoScrollRef when user scrolls manually
@@ -148,7 +191,9 @@ export default function SkillBuildModal({
const isStreamingCompleteRef = useRef(false);
// Track current tabs during streaming to avoid stale closure issues
- const streamingTabsRef = useRef([{ path: "SKILL.md", content: "" }]);
+ const streamingTabsRef = useRef([
+ { path: "SKILL.md", content: "" },
+ ]);
// AbortController ref for stopping streaming
const abortControllerRef = useRef(null);
@@ -166,29 +211,6 @@ export default function SkillBuildModal({
content: string;
} | null>(null);
- // Whether the user is in multi-turn refinement mode (has already received a draft).
- // Used to switch the placeholder from "创建" to "继续修改" and to pass existing_skill.
- const [isMultiTurn, setIsMultiTurn] = useState(false);
-
- // Name input dropdown control
- const [isNameDropdownOpen, setIsNameDropdownOpen] = useState(false);
- const [isTagsFocused, setIsTagsFocused] = useState(false);
-
- // Create/Update mode detection
- const [isCreateMode, setIsCreateMode] = useState(true);
-
- // Recent skills (sorted by update_time descending, take top 5)
- const recentSkills = useMemo(() => {
- return [...allSkills]
- .filter((s) => s.update_time)
- .sort((a, b) => {
- const timeA = new Date(a.update_time!).getTime();
- const timeB = new Date(b.update_time!).getTime();
- return timeB - timeA;
- })
- .slice(0, MAX_RECENT_SKILLS);
- }, [allSkills]);
-
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
@@ -216,15 +238,10 @@ export default function SkillBuildModal({
// Reset task ID
taskIdRef.current = "";
setActiveTab("interactive");
- setSelectedSkillName("");
setUploadFile(null);
- setSearchResults([]);
setChatMessages([]);
setChatInput("");
setInteractiveSkillName("");
- setIsNameDropdownOpen(false);
- setIsTagsFocused(false);
- setIsCreateMode(true);
setUploadExtractingName(false);
setUploadExtractedSkillName("");
setThinkingDescription("");
@@ -237,9 +254,6 @@ export default function SkillBuildModal({
setSummaryContent("");
currentAssistantIdRef.current = "";
setAccumulatedDraft(null);
- setIsMultiTurn(false);
- setEditingTabKey(null);
- setEditingTabName("");
}
}, [isOpen]);
@@ -256,7 +270,8 @@ export default function SkillBuildModal({
if (!currentAssistantIdRef.current) return;
if (!summaryContent) return;
setChatMessages((prev) => {
- if (!prev.some((m) => m.id === currentAssistantIdRef.current)) return prev;
+ if (!prev.some((m) => m.id === currentAssistantIdRef.current))
+ return prev;
return prev.map((msg) =>
msg.id === currentAssistantIdRef.current
? { ...msg, content: summaryContent }
@@ -265,23 +280,6 @@ export default function SkillBuildModal({
});
}, [summaryContent]);
- // Detect create/update mode when skill name changes
- useEffect(() => {
- const nameValue = interactiveSkillName.trim();
- if (nameValue) {
- const matchedSkill = findSkillByName(nameValue, allSkills);
- setIsCreateMode(!matchedSkill);
- if (matchedSkill) {
- setSelectedSkillName(matchedSkill.name);
- // Load all skill data including files
- loadSkillData(nameValue);
- }
- } else {
- setIsCreateMode(true);
- setSelectedSkillName("");
- }
- }, [interactiveSkillName, allSkills, form]);
-
// Detect create/update mode when extracted skill name changes (upload tab)
const [uploadIsCreateMode, setUploadIsCreateMode] = useState(true);
useEffect(() => {
@@ -294,86 +292,57 @@ export default function SkillBuildModal({
}
}, [uploadExtractedSkillName, allSkills]);
- // Dropdown options based on input state
- const dropdownOptions = useMemo(() => {
- if (!interactiveSkillName || interactiveSkillName.trim() === "") {
- return recentSkills.map((skill) => ({
- value: skill.name,
- label: (
-
- {skill.name}
- {skill.source}
-
- ),
- }));
- }
- return searchResults.map((skill) => ({
- value: skill.name,
- label: (
-
- {skill.name}
- {skill.source}
-
- ),
- }));
- }, [interactiveSkillName, searchResults, recentSkills]);
+ useEffect(() => {
+ if (!isOpen || !editingSkill) return;
+ const skillName = editingSkill.name?.trim() || "";
+ let cancelled = false;
- // Determine if dropdown should be open
- const shouldShowDropdown = isNameDropdownOpen && !isTagsFocused;
+ const applySkillInfo = (
+ skill: Partial & { content?: string | null }
+ ) => {
+ if (cancelled) return;
+ const nextName = skill.name?.trim() || skillName;
+ setInteractiveSkillName(nextName);
+ form.setFieldsValue({
+ name: nextName,
+ description: skill.description || "",
+ source: skill.source || "custom",
+ tags: Array.isArray(skill.tags) ? skill.tags : [],
+ });
+ setSkillTabs([{ path: "SKILL.md", content: skill.content || "" }]);
+ setActiveSkillTab("SKILL.md");
+ };
- const handleNameSearch = (value: string) => {
- setInteractiveSkillName(value);
- if (!value || value.trim() === "") {
- setSearchResults([]);
- } else {
- const results = searchSkillsByNameUtil(value, allSkills);
- setSearchResults(results);
- }
- };
+ setActiveTab("interactive");
+ applySkillInfo({
+ name: skillName,
+ description: editingSkill.description || "",
+ source: editingSkill.source || "custom",
+ tags: editingSkill.tags || [],
+ });
- const handleNameSelect = (value: string) => {
- setSelectedSkillName(value);
- setInteractiveSkillName(value);
- setIsNameDropdownOpen(false);
- };
+ void fetchSkillById(editingSkill.skill_id).then((result) => {
+ if (result.success && result.data) {
+ applySkillInfo(result.data);
+ }
+ });
- // Load skill data when name is selected or typed
- const loadSkillData = async (skillName: string) => {
- const skill = allSkills.find((s) => s.name === skillName);
- if (!skill) return;
-
- const fieldsToSet = {
- name: skill.name,
- description: skill.description || "",
- source: skill.source || "自定义",
- tags: skill.tags || [],
- content: skill.content || "",
+ return () => {
+ cancelled = true;
};
- form.setFieldsValue(fieldsToSet);
-
- await loadSkillFiles(skillName);
- };
+ }, [isOpen, editingSkill?.skill_id]);
- const handleNameChange = (value: string) => {
+ const handleNameChange = (event: ChangeEvent) => {
+ const value = event.target.value;
setInteractiveSkillName(value);
- if (!value || value.trim() === "") {
- setSelectedSkillName("");
+ form.setFieldsValue({ name: value });
+ if (!value.trim()) {
// Reset skillTabs when input is cleared
setSkillTabs([{ path: "SKILL.md", content: "" }]);
setActiveSkillTab("SKILL.md");
}
};
- const handleNameFocus = () => {
- setIsNameDropdownOpen(true);
- };
-
- const handleNameBlur = () => {
- setTimeout(() => {
- setIsNameDropdownOpen(false);
- }, 200);
- };
-
const closeModal = () => {
form.resetFields();
onCancel();
@@ -387,27 +356,51 @@ export default function SkillBuildModal({
const handleManualSubmit = async () => {
try {
const values = await form.validateFields();
+ if (isEditMode && editingSkill && onBeforeEditSave) {
+ const shouldContinue = await onBeforeEditSave(editingSkill);
+ if (!shouldContinue) {
+ return;
+ }
+ }
setIsSubmitting(true);
- const skillTab = skillTabs.find(t => t.path === "SKILL.md");
+ const skillTab = skillTabs.find((t) => t.path === "SKILL.md");
const content = skillTab?.content || "";
const extraFiles = skillTabs
- .filter(t => t.path !== "SKILL.md")
- .map(t => ({
+ .filter((t) => t.path !== "SKILL.md")
+ .map((t) => ({
path: t.path,
content: t.content || "",
}));
await submitSkillForm(
- { ...values, content, files: extraFiles.length > 0 ? extraFiles : undefined } as SkillData,
+ {
+ ...values,
+ content,
+ files: extraFiles.length > 0 ? extraFiles : undefined,
+ } as SkillData,
allSkills,
onSuccess,
closeModal,
- t
+ t,
+ isEditMode && editingSkill?.skill_id
+ ? { mode: "edit", skillId: editingSkill.skill_id }
+ : { mode: "create" }
);
} catch (error) {
log.error("Skill create/update error:", error);
+ const errorMessage = error instanceof Error ? error.message : "";
+ if (/already exists|409/.test(errorMessage)) {
+ form.setFields([
+ {
+ name: "name",
+ errors: ["技能名称已存在,请修改名称"],
+ },
+ ]);
+ return;
+ }
+ message.error(t("skillManagement.message.submitFailed"));
} finally {
setIsSubmitting(false);
}
@@ -454,6 +447,17 @@ export default function SkillBuildModal({
}
};
+ const ensureStreamingTab = (tabPath: string) => {
+ setSkillTabs((prev) => {
+ const newTabs = prev.find((tab) => tab.path === tabPath)
+ ? prev
+ : [...prev, { path: tabPath, content: "" }];
+ streamingTabsRef.current = newTabs;
+ shouldAutoScrollRef.current[tabPath] = true;
+ return newTabs;
+ });
+ };
+
// Assemble skill files into XML-like format for agent consumption
const assembleSkillContent = (tabs: SkillFileContent[]): string => {
const parts: string[] = [];
@@ -469,102 +473,26 @@ export default function SkillBuildModal({
return parts.join("\n\n");
};
- // Load all files for a skill into skillTabs
- const loadSkillFiles = async (skillName: string) => {
- try {
- const files = await fetchSkillFiles(skillName);
- if (files.length === 0) {
- // Fallback: load SKILL.md content from the skill list item
- const skill = allSkills.find((s) => s.name === skillName);
- if (skill?.content) {
- setSkillTabs([{ path: "SKILL.md", content: skill.content }]);
- }
- return;
- }
-
- // Flatten file tree and get all file paths.
- // The root node's name IS the skill_name — skip the root itself and
- // start from its children so paths stay relative (e.g. "SKILL.md", not "skill_name/SKILL.md").
- const flattenFiles = (nodes: SkillFileNode[], prefix = ""): string[] => {
- const result: string[] = [];
- for (const node of nodes) {
- if (node.type === "directory" && node.name === skillName && prefix === "") {
- // Root directory — recurse into children without prepending the root name
- if (node.children) {
- result.push(...flattenFiles(node.children, ""));
- }
- } else {
- const fullPath = prefix ? `${prefix}/${node.name}` : node.name;
- if (node.type === "file") {
- result.push(fullPath);
- } else if (node.children) {
- result.push(...flattenFiles(node.children, fullPath));
- }
- }
- }
- return result;
- };
-
- const filePaths = flattenFiles(files);
-
- // Load content for each file
- const tabsContent: SkillFileContent[] = [];
- for (const filePath of filePaths) {
- const content = await fetchSkillFileContent(skillName, filePath);
- tabsContent.push({ path: filePath, content: content || "" });
- }
-
- // Sort so SKILL.md is always first
- tabsContent.sort((a, b) => {
- if (a.path === "SKILL.md") return -1;
- if (b.path === "SKILL.md") return 1;
- return a.path.localeCompare(b.path);
- });
-
- setSkillTabs(tabsContent);
- setActiveSkillTab("SKILL.md");
- } catch (error) {
- log.error("Failed to load skill files:", error);
- if (error instanceof SkillFilesAccessDeniedError) {
- message.warning(error.message);
- return;
- }
- // Fallback to basic content
- const skill = allSkills.find((s) => s.name === skillName);
- if (skill?.content) {
- setSkillTabs([{ path: "SKILL.md", content: skill.content }]);
- setActiveSkillTab("SKILL.md");
- }
- }
- };
-
// Parse frontmatter YAML and update form fields
const parseAndUpdateFrontmatter = (frontmatterYaml: string) => {
- try {
- // Parse the frontmatter using js-yaml
- const parsed = yaml.load(frontmatterYaml) as Record | null;
- if (parsed && typeof parsed === "object") {
- const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
- const description = typeof parsed.description === "string" ? parsed.description.trim() : "";
- const tags = Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === "string") : [];
-
- if (name) {
- form.setFieldsValue({ name });
- setInteractiveSkillName(name);
- const existingSkill = allSkills.find(
- (s) => s.name.toLowerCase() === name.toLowerCase()
- );
- setIsCreateMode(!existingSkill);
- }
- if (description) {
- form.setFieldsValue({ description });
- }
- if (tags.length > 0) {
- form.setFieldsValue({ tags });
- }
- }
- } catch (e) {
- log.warn("Failed to parse frontmatter:", e);
+ const parsed = parseStreamedFrontmatter(frontmatterYaml);
+ if (!parsed) {
+ return;
+ }
+
+ const updates: Partial = {};
+ if (parsed.name && !isEditMode) {
+ updates.name = parsed.name;
+ setInteractiveSkillName(parsed.name);
+ }
+ if (parsed.description) {
+ updates.description = parsed.description;
+ }
+ if (parsed.tags.length > 0) {
+ updates.tags = parsed.tags;
+ }
+ if (Object.keys(updates).length > 0) {
+ form.setFieldsValue(updates);
}
};
@@ -586,7 +514,9 @@ export default function SkillBuildModal({
formValues.description ? `当前技能描述:${formValues.description}` : "",
formValues.tags?.length ? `当前标签:${formValues.tags.join(", ")}` : "",
assembledContent ? `当前技能文件内容:\n${assembledContent}` : "",
- ].filter(Boolean).join("\n\n");
+ ]
+ .filter(Boolean)
+ .join("\n\n");
const userMessage: ChatMessage = {
id: Date.now().toString(),
@@ -598,7 +528,9 @@ export default function SkillBuildModal({
setChatMessages((prev) => [...prev, userMessage]);
setIsChatLoading(true);
setIsThinkingVisible(true);
- setThinkingDescription(t("skillManagement.generatingSkill") || "生成技能内容中 ...");
+ setThinkingDescription(
+ t("skillManagement.generatingSkill") || "生成技能内容中 ..."
+ );
// Clear content input before streaming — start fresh so the streamed content
// reflects the (possibly refined) result of this turn.
@@ -614,7 +546,12 @@ export default function SkillBuildModal({
setChatMessages((prev) => [
...prev,
- { id: assistantId, role: "assistant", content: "", timestamp: new Date() },
+ {
+ id: assistantId,
+ role: "assistant",
+ content: "",
+ timestamp: new Date(),
+ },
]);
currentAssistantIdRef.current = assistantId;
@@ -633,12 +570,14 @@ export default function SkillBuildModal({
await createSkillStream(
{
user_request: userPrompt,
- existing_skill: draft ? {
- name: draft.name || formValues.name || "",
- description: draft.description || formValues.description || "",
- tags: draft.tags?.length ? draft.tags : (formValues.tags || []),
- content: assembledContent,
- } : undefined,
+ existing_skill: draft
+ ? {
+ name: draft.name || formValues.name || "",
+ description: draft.description || formValues.description || "",
+ tags: draft.tags?.length ? draft.tags : formValues.tags || [],
+ content: assembledContent,
+ }
+ : undefined,
complexity: "complicated",
language: "zh",
},
@@ -653,33 +592,26 @@ export default function SkillBuildModal({
setIsThinkingVisible(visible);
},
onStepCount: (step) => {
- setThinkingDescription(THINKING_STEPS_ZH.find((s) => s.step === step)?.description || "生成技能内容中 ...");
+ setThinkingDescription(
+ THINKING_STEPS_ZH.find((s) => s.step === step)?.description ||
+ "生成技能内容中 ..."
+ );
},
onFrontmatter: (content) => {
- // Accumulate frontmatter content as it streams in
- // Parse frontmatter incrementally as it streams to update form fields
frontmatterBufferRef.current += content;
- // Try to parse incrementally for form field updates
- try {
- const parsed = yaml.load(frontmatterBufferRef.current) as Record | null;
- if (parsed && typeof parsed === "object") {
- const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
- const description = typeof parsed.description === "string" ? parsed.description.trim() : "";
- const tags = Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === "string") : [];
-
- if (name) {
- form.setFieldsValue({ name });
- setInteractiveSkillName(name);
- }
- if (description) {
- form.setFieldsValue({ description });
- }
- if (tags.length > 0) {
- form.setFieldsValue({ tags });
- }
- }
- } catch {
- // YAML not complete yet, will parse when skill body starts
+ const parsed = parseStreamedFrontmatter(
+ frontmatterBufferRef.current
+ );
+ if (!parsed) return;
+ if (parsed.name && !isEditMode) {
+ form.setFieldsValue({ name: parsed.name });
+ setInteractiveSkillName(parsed.name);
+ }
+ if (parsed.description) {
+ form.setFieldsValue({ description: parsed.description });
+ }
+ if (parsed.tags.length > 0) {
+ form.setFieldsValue({ tags: parsed.tags });
}
},
onSkillBody: (content) => {
@@ -693,13 +625,7 @@ export default function SkillBuildModal({
if (isStreamingCompleteRef.current) return;
if (isNewFile) {
- // New file detected, create a new tab
- setSkillTabs((prev) => {
- const newTabs = prev.find((t) => t.path === path) ? prev : [...prev, { path, content: "" }];
- streamingTabsRef.current = newTabs;
- shouldAutoScrollRef.current[path] = true;
- return newTabs;
- });
+ ensureStreamingTab(path);
}
updateTabContent(path, content);
@@ -717,55 +643,34 @@ export default function SkillBuildModal({
isStreamingCompleteRef.current = true;
// Get SKILL.md content and strip frontmatter for textarea display
- const skillTab = result.skillTabs.find(t => t.path === "SKILL.md");
+ const skillTab = result.skillTabs.find(
+ (t) => t.path === "SKILL.md"
+ );
const fullContent = skillTab?.content || "";
if (fullContent || result.skillTabs.length > 0) {
// Strip frontmatter from SKILL.md content for textarea display
const skillInfo = extractSkillInfoFromContent(fullContent);
- const contentWithoutFrontmatter = skillInfo?.contentWithoutFrontmatter || "";
+ const contentWithoutFrontmatter =
+ skillInfo?.contentWithoutFrontmatter || "";
- // Use the current tabs from ref (avoids stale closure)
const currentTabs = streamingTabsRef.current;
-
- // Build updated tabs: start with current tabs, update matching ones from backend
- const updatedTabs = currentTabs.map((tab) => {
- const backendTab = result.skillTabs.find((t) => t.path === tab.path);
- if (tab.path === "SKILL.md") {
- return { ...tab, content: contentWithoutFrontmatter };
- }
- if (backendTab) {
- return { ...tab, content: backendTab.content || tab.content };
- }
- return tab;
- });
-
- // Add any new tabs from backend that don't exist in current tabs
- const newTabsFromBackend = result.skillTabs.filter((t) => !currentTabs.find((tab) => tab.path === t.path));
- const finalTabs = [...updatedTabs, ...newTabsFromBackend];
-
- // Sort so SKILL.md is always first
- finalTabs.sort((a, b) => {
- if (a.path === "SKILL.md") return -1;
- if (b.path === "SKILL.md") return 1;
- return a.path.localeCompare(b.path);
- });
+ const { updatedTabs, finalTabs } = mergeGeneratedSkillTabs(
+ currentTabs,
+ result.skillTabs,
+ contentWithoutFrontmatter
+ );
setSkillTabs(finalTabs);
- // Update form fields from parsed skill info
- if (skillInfo && skillInfo.name) {
+ if (skillInfo?.name && !isEditMode) {
form.setFieldsValue({ name: skillInfo.name });
setInteractiveSkillName(skillInfo.name);
- const existingSkill = allSkills.find(
- (s) => s.name.toLowerCase() === skillInfo.name?.toLowerCase()
- );
- setIsCreateMode(!existingSkill);
}
- if (skillInfo && skillInfo.description) {
+ if (skillInfo?.description) {
form.setFieldsValue({ description: skillInfo.description });
}
- if (skillInfo && skillInfo.tags && skillInfo.tags.length > 0) {
+ if (skillInfo?.tags?.length) {
form.setFieldsValue({ tags: skillInfo.tags });
}
@@ -774,11 +679,12 @@ export default function SkillBuildModal({
const newDraft = {
name: skillInfo?.name || draft?.name || "",
description: skillInfo?.description || draft?.description || "",
- tags: skillInfo?.tags?.length ? skillInfo.tags : (draft?.tags || []),
+ tags: skillInfo?.tags?.length
+ ? skillInfo.tags
+ : draft?.tags || [],
content: assembledDraft,
};
setAccumulatedDraft(newDraft);
- setIsMultiTurn(true);
// Scroll to bottom after content is fully loaded
setTimeout(() => scrollTextareaToBottom("SKILL.md"), 0);
@@ -816,20 +722,6 @@ export default function SkillBuildModal({
}
};
- // Handle chat clear - reset all form fields
- const handleChatClear = async () => {
- await clearChatAndTempFile();
- setChatMessages([]);
- form.resetFields(["name", "description", "source", "tags", "content"]);
- setInteractiveSkillName("");
- setSkillTabs([{ path: "SKILL.md", content: "" }]);
- streamingTabsRef.current = [{ path: "SKILL.md", content: "" }];
- setActiveSkillTab("SKILL.md");
- setSummaryContent("");
- setAccumulatedDraft(null);
- setIsMultiTurn(false);
- };
-
// Handle stop - cancel the ongoing streaming request
const handleStop = async () => {
// Call backend stop API first
@@ -859,362 +751,20 @@ export default function SkillBuildModal({
// Scroll to bottom of chat when new messages arrive
useEffect(() => {
if (chatContainerRef.current) {
- chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
+ chatContainerRef.current.scrollTop =
+ chatContainerRef.current.scrollHeight;
}
}, [chatMessages]);
- const renderInteractiveTab = () => {
- return (
-
- {/* Left side: Chat dialog */}
-
- {/* Chat header */}
-
-
- {t("skillManagement.tabs.interactive")}
-
- {chatMessages.length > 0 && (
-
-
-
- )}
-
-
- {/* Chat messages area */}
-
- {chatMessages.length === 0 && (
-
- {t("skillManagement.form.chatPlaceholder")}
-
- )}
- {chatMessages.map((msg) => (
-
-
- {msg.role === "assistant" && msg.id === currentAssistantIdRef.current && isThinkingVisible ? (
-
-
- {thinkingDescription && (
-
- {thinkingDescription}
-
- )}
-
- ) : msg.role === "assistant" ? (
-
-
-
- ) : (
- {msg.content}
- )}
-
-
- ))}
-
-
- {/* Chat input area */}
-
-
-
-
-
-
- {/* Right side: Form */}
-
- {/* Form header area */}
-
-
- {t("skillManagement.form.newSkillHint")}
-
- ) : (
-
- {t("skillManagement.form.existingSkillHint")}
-
- )
- ) : undefined}
- validateStatus={interactiveSkillName.trim() ? (isCreateMode ? "success" : "warning") : undefined}
- >
- 0}
- options={dropdownOptions}
- onSearch={handleNameSearch}
- onSelect={handleNameSelect}
- onChange={handleNameChange}
- onFocus={handleNameFocus}
- onBlur={handleNameBlur}
- value={interactiveSkillName}
- placeholder={t("skillManagement.form.namePlaceholder")}
- allowClear
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setIsTagsFocused(true)}
- onBlur={() => setIsTagsFocused(false)}
- open={false}
- style={{ width: "100%", minWidth: 200 }}
- popupMatchSelectWidth={false}
- />
-
-
-
-
-
-
-
- {/* Tabs area */}
-
- setActiveSkillTab(key)}
- type="card"
- size="small"
- className="flex-1 flex flex-col"
- tabBarStyle={{ marginBottom: 0, flexShrink: 0 }}
- tabBarExtraContent={{
- right: (
- }
- onClick={() => {
- const newPath = `file_${Date.now()}.md`;
- setSkillTabs((prev) => [...prev, { path: newPath, content: "" }]);
- setActiveSkillTab(newPath);
- shouldAutoScrollRef.current[newPath] = true;
- }}
- className="add-tab-btn"
- />
- ),
- }}
- items={skillTabs.map((tab) => ({
- key: tab.path,
- label: (
-
- {editingTabKey === tab.path ? (
- setEditingTabName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- setSkillTabs((prev) =>
- prev.map((t) => (t.path === editingTabKey ? { ...t, path: editingTabName } : t))
- );
- if (activeSkillTab === editingTabKey) {
- setActiveSkillTab(editingTabName);
- }
- setEditingTabKey(null);
- setEditingTabName("");
- } else if (e.key === "Escape") {
- e.stopPropagation();
- setEditingTabKey(null);
- setEditingTabName("");
- }
- }}
- onBlur={() => {
- setSkillTabs((prev) =>
- prev.map((t) => (t.path === editingTabKey ? { ...t, path: editingTabName } : t))
- );
- if (activeSkillTab === editingTabKey) {
- setActiveSkillTab(editingTabName);
- }
- setEditingTabKey(null);
- setEditingTabName("");
- }}
- onClick={(e) => e.stopPropagation()}
- />
- ) : (
-
- {tab.path}
-
- )}
- {!isStreaming && (
-
- {tab.path !== "SKILL.md" && (
- {
- e.preventDefault();
- e.stopPropagation();
- setTimeout(() => {
- setEditingTabKey(tab.path);
- setEditingTabName(tab.path);
- }, 0);
- }}
- title="Rename"
- >
-
-
- )}
- {tab.path !== "SKILL.md" && (
- {
- e.preventDefault();
- e.stopPropagation();
- const newTabs = skillTabs.filter((t) => t.path !== tab.path);
- setSkillTabs(newTabs);
- if (activeSkillTab === tab.path) {
- setActiveSkillTab(newTabs[0]?.path || "");
- }
- }}
- title="Delete"
- >
-
-
- )}
-
- )}
-
- ),
- children: (
-
-
-
- );
- };
+ const modalBodyFrame = "min(92vh, 760px)";
+ const editingSkillName =
+ editingSkill?.name?.trim() || interactiveSkillName.trim();
const renderUploadTab = () => {
const existingSkill = allSkills.find(
- (s) => s.name.trim().toLowerCase() === uploadExtractedSkillName.trim().toLowerCase()
+ (s) =>
+ s.name.trim().toLowerCase() ===
+ uploadExtractedSkillName.trim().toLowerCase()
);
const handleFileSelection = async (files: FileList | null) => {
@@ -1234,7 +784,9 @@ export default function SkillBuildModal({
if (!extractedName || !extractedDesc) {
setUploadFile(null);
setUploadExtractedSkillName("");
- message.warning(t("skillManagement.message.nameOrDescriptionMissing"));
+ message.warning(
+ t("skillManagement.message.nameOrDescriptionMissing")
+ );
return;
}
setUploadExtractedSkillName(extractedName);
@@ -1244,179 +796,333 @@ export default function SkillBuildModal({
};
return (
-
-
- {/* Left: Name display + Upload Dragger */}
-
-
- {/* Name field */}
-
-
-
-
-
- {uploadExtractedSkillName && existingSkill && (
-
- {t("skillManagement.form.existingSkillHint")}
-
- )}
- {uploadExtractedSkillName && !existingSkill && (
-
- {t("skillManagement.form.newSkillHint")}
-
+
+
+ 安装
+
+ {t("skillManagement.form.uploadHint")}
+
+
+
+
+
+
+
+
+ {uploadExtractedSkillName && existingSkill ? (
+
+ {t("skillManagement.form.uploadSkillExists")}
+
+ ) : null}
+ {uploadExtractedSkillName && !existingSkill ? (
+
+ {t("skillManagement.form.newSkillHint")}
+
+ ) : null}
+
+
+
+
+
+ {uploadFile ? (
+
+
+
+ {t("knowledgeBase.upload.completed")}
+
+ 1
-
- {/* Upload area */}
-
- {
- const input = document.getElementById("skill-upload-input") as HTMLInputElement;
- input?.click();
- }}>
- { e.preventDefault(); e.stopPropagation(); }}
- onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); }}
- onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); }}
- onDrop={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleFileSelection(e.dataTransfer.files);
- }}
- >
-