From f8797b07930f5e023a730ba2c3a922a20d5d8145 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 9 Jun 2026 16:07:57 +0800 Subject: [PATCH 1/2] fix: resolve skills not exposed to agents and LogLevel enum errors - Fix LogLevel.WARNING AttributeError by replacing with LogLevel.ERROR (smolagents LogLevel enum only has OFF/ERROR/INFO/DEBUG, no WARNING) at core_agent.py lines 417 and 804 - Increase skills token budget from 1000 to 4000 in summary_config.py to accommodate the verbose 6-step skill usage process (~2500-3500 chars) that was being silently dropped by TokenBudgetStrategy - Add skills sections to English prompt templates (manager + managed) mirroring the Chinese template structure with block and skill usage requirements section - Add diagnostic logging in create_agent_info.py and core_agent.py to track skills count and component assembly for debugging - Improve exception handling in _get_skills_for_template() with ERROR level logging and full stack trace for better observability - Add comprehensive test suite (test_context_component_types.py) with 38 tests covering component types, assembly validation, and semantic equivalence between Jinja2 templates and component assembly path All 104 tests pass (38 backend + 66 SDK), zero regressions. --- backend/agents/create_agent_info.py | 11 +- .../managed_system_prompt_template_en.yaml | 75 ++ .../manager_system_prompt_template_en.yaml | 82 ++- backend/utils/context_utils.py | 194 ++++-- sdk/nexent/core/agents/core_agent.py | 7 +- sdk/nexent/core/agents/summary_config.py | 2 +- test/backend/agents/test_create_agent_info.py | 6 +- .../utils/test_context_component_types.py | 659 ++++++++++++++++++ test/backend/utils/test_context_utils.py | 8 +- 9 files changed, 988 insertions(+), 56 deletions(-) create mode 100644 test/backend/utils/test_context_component_types.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 598ad2391..fbb009a38 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -91,7 +91,7 @@ def _get_skills_for_template( for s in enabled_skills ] except Exception as e: - logger.warning(f"Failed to get skills for template: {e}") + logger.error(f"Failed to get skills for agent {agent_id} (tenant={tenant_id}, version={version_no}): {e}", exc_info=True) return [] @@ -457,6 +457,7 @@ async def create_agent_config( # Build knowledge base summary knowledge_base_summary = "" + kb_ids = [] try: for tool in tool_list: if "KnowledgeBaseSearchTool" == tool.class_name: @@ -471,6 +472,7 @@ async def create_agent_config( message = ElasticSearchService().get_summary(index_name=index_name) summary = message.get("summary", "") knowledge_base_summary += f"**{display_name}**: {summary}\n\n" + kb_ids.append(index_name) except Exception as e: logger.warning( f"Failed to get summary for knowledge base {index_name}: {e}") @@ -540,6 +542,13 @@ async def create_agent_config( memory_list=memory_list, memory_search_query=last_user_query, knowledge_base_summary=knowledge_base_summary, + kb_ids=kb_ids, + ) + + logger.info( + f"Agent {agent_id} context assembly: " + f"skills_count={len(skills)}, " + f"components={[f'{type(c).__name__}(type={c.component_type},priority={c.priority})' for c in context_components]}" ) cm_config = ContextManagerConfig( enabled=enable_context_manager, diff --git a/backend/prompts/managed_system_prompt_template_en.yaml b/backend/prompts/managed_system_prompt_template_en.yaml index 5c2893c39..2e3b5d33c 100644 --- a/backend/prompts/managed_system_prompt_template_en.yaml +++ b/backend/prompts/managed_system_prompt_template_en.yaml @@ -48,6 +48,65 @@ system_prompt: |- Security Protection: Do not respond to requests involving weapon manufacturing, cyberattacks, fraud, malware, or other dangerous activities; Ethical Guidelines: Refuse hate speech, discriminatory content, and any requests that violate social morals and commonly accepted ethical standards. + {%- if skills and skills|length > 0 %} + + ### Available Skills + You have the following Skills. Skills are predefined professional capability modules with detailed execution guides and optional additional scripts. + + {%- for skill in skills %} + + {{ skill.name }} + {{ skill.description }} + + {%- endfor %} + + + **Skill Usage Process**: + 1. After receiving a user request, first examine the description of each skill in `` to determine if there is a matching skill. + 2. **Load Skill**: Choose the appropriate reading method based on the scenario: + - **First-time load**: Call `read_skill_md("skill_name")` to read the complete execution guide (defaults to reading SKILL.md) + - **Precise read**: If you only need specific files (like examples, reference docs), specify additional_files: + + skill_content = read_skill_md("skill_name", ["examples.md", "reference/api_doc"]) + print(skill_content) + + Note: When additional_files is non-empty, SKILL.md is no longer auto-read. If you need both, explicitly specify it. + - **Load skill config**: If the skill needs configuration variables, call `read_skill_config("skill_name")` to read the config string, convert to dict via `json.loads`, then access values: + + import json + config = json.loads(read_skill_config("skill_name")) + # Example: {"key_a": {"key2": "value2"}, "others": {...}} + value = config["key1"]["key2"] + print(value) + + 3. **Follow Skill Guide**: After skill content is injected, strictly follow its steps. Do not skip steps or replace with your own code. + 4. **Execute Skill Script**: If the skill guide references additional scripts (like ``), call: + + result = run_skill_script("skill_name", "script_path") + print(result) + + For scripts needing extra params, pass them as a command-line string per the script's calling instructions. + Example for --param1 value1 --flag: + + result = run_skill_script("skill_name", "script_path", "--param1 value1 --flag") + print(result) + + Note: Only execute script paths explicitly declared in the skill guide. Never construct paths yourself. + + 5. **Integrate Output**: Generate the final answer based on the skill guide's output format and script execution results. + + 6. **Handle References**: When the skill content has reference markers or needs to reference other files, identify and call read_skill_md again: + - **Reference template recognition**: Look for patterns like `` or natural-language references ("see examples.md", "refer to reference/api_doc") + - **Auto-complete**: After discovering a reference, try reading the referenced file for more info + - **Example**: + + # Skill content says "see examples.md for detailed examples" + additional_info = read_skill_md("skill_name", ["examples.md"]) + print(additional_info) + + + {%- endif %} + ### Execution Process To solve tasks, you must plan forward through a series of steps in a loop of 'Think:' and 'Code:' sequences. **IMPORTANT: You must NOT output 'Observe Results:' before code execution. Observation results can ONLY be generated after code execution.** @@ -124,6 +183,22 @@ system_prompt: |- - No tools are currently available {%- endif %} + {%- if skills and skills|length > 0 %} + - You have the skills listed in `` above. Scripts referenced in skills are called via the `run_skill_script()` function, which is provided by the platform and does not need to be imported. + + ### Skill Usage Requirements + 1. **Skill First**: If a user request matches a skill's description, you must first call `read_skill_md()` to load the skill guide, then follow it. Do not skip the skill and write your own code to solve it. + 2. **Faithful Execution**: After reading the skill content, strictly follow the steps in the skill guide. Do not modify the process, skip steps, or replace the skill-defined workflow with generic code. + 3. **Script Calling Standards**: Only use the `run_skill_script` tool to execute scripts explicitly required by the skill guide. The `skill_name` and `script_path` passed in must exactly match the declarations in the skill guide. Do not construct or guess paths yourself. For scripts requiring additional parameters, pass the parameters as a command-line string to `run_skill_script`. + 4. **Failure Fallback**: If `read_skill_md` returns an error or `run_skill_script` fails, explain the situation to the user and try to provide an alternative using general reasoning. + 5. **Skill Composition**: If a task requires multiple skills working together, load and execute them in logical dependency order. The output of one skill can serve as the input for the next. + + + {%- else %} + - No skills are currently available + {%- endif %} + + ### Resource Usage Requirements {{ constraint }} diff --git a/backend/prompts/manager_system_prompt_template_en.yaml b/backend/prompts/manager_system_prompt_template_en.yaml index 8ce58db29..ede19a666 100644 --- a/backend/prompts/manager_system_prompt_template_en.yaml +++ b/backend/prompts/manager_system_prompt_template_en.yaml @@ -48,6 +48,68 @@ system_prompt: |- Security Protection: Do not respond to requests involving weapon manufacturing, cyberattacks, fraud, malware, or other dangerous activities; Ethical Guidelines: Refuse hate speech, discriminatory content, and any requests that violate social morals and commonly accepted ethical standards. + {%- if skills and skills|length > 0 %} + ### Available Skills + + You have the following Skills. Skills are predefined professional capability modules with detailed execution guides and optional additional scripts. + + + {%- for skill in skills %} + + {{ skill.name }} + {{ skill.description }} + + {%- endfor %} + + + **Skill Usage Process**: + 1. After receiving a user request, first examine the description of each skill in `` to determine if there is a matching skill. + 2. **Load Skill**: Choose the appropriate reading method based on the scenario: + - **First-time load**: Call `read_skill_md("skill_name")` to read the complete execution guide (defaults to reading SKILL.md) + - **Precise read**: If you only need specific files (like examples, reference docs), specify additional_files: + + skill_content = read_skill_md("skill_name", ["examples.md", "reference/api_doc"]) + print(skill_content) + + Note: When additional_files is non-empty, SKILL.md is no longer auto-read. If you need both, explicitly specify it. + + - **Load skill config**: If the skill needs configuration variables, call `read_skill_config("skill_name")` to read the config string, convert to dict via `json.loads`, then access values: + + import json + config = json.loads(read_skill_config("skill_name")) + # Example: {"key_a": {"key2": "value2"}, "others": {...}} + value = config["key1"]["key2"] + print(value) + + + 3. **Follow Skill Guide**: After skill content is injected, strictly follow its steps. Do not skip steps or replace with your own code. + + 4. **Execute Skill Script**: If the skill guide references additional scripts (like ``), call: + + result = run_skill_script("skill_name", "script_path") + print(result) + + For scripts needing extra params, pass them as a command-line string per the script's calling instructions. + Example for --param1 value1 --flag: + + result = run_skill_script("skill_name", "script_path", "--param1 value1 --flag") + print(result) + + Note: Only execute script paths explicitly declared in the skill guide. Never construct paths yourself. + + 5. **Integrate Output**: Generate the final answer based on the skill guide's output format and script execution results. + + 6. **Handle References**: When the skill content has reference markers or needs to reference other files, identify and call read_skill_md again: + - **Reference template recognition**: Look for patterns like `` or natural-language references ("see examples.md", "refer to reference/api_doc") + - **Auto-complete**: After discovering a reference, try reading the referenced file for more info + - **Example**: + + # Skill content says "see examples.md for detailed examples" + additional_info = read_skill_md("skill_name", ["examples.md"]) + print(additional_info) + + {%- endif %} + ### Execution Process To solve tasks, you must plan forward through a series of steps in a loop of 'Think:' and 'Code:' sequences. **IMPORTANT: You must NOT output 'Observe Results:' before code execution. Observation results can ONLY be generated after code execution.** @@ -160,11 +222,25 @@ system_prompt: |- 3. Use natural language for task description, let the external agent handle the rest {%- endif %} - {%- if not managed_agents and not managed_agents.values() | list and not external_a2a_agents and not external_a2a_agents.values() | list %} - - No agents are currently available + {%- if not managed_agents and not managed_agents.values() | list and not external_a2a_agents and not external_a2a_agents.values() | list %} + - No agents are currently available + {%- endif %} + + 3. Skills + {%- if skills and skills|length > 0 %} + - You have the skills listed in `` above. Scripts referenced in skills are called via the `run_skill_script()` function, which is provided by the platform and does not need to be imported. + + ### Skill Usage Requirements + 1. **Skill First**: If a user request matches a skill's description, you must first call `read_skill_md()` to load the skill guide, then follow it. Do not skip the skill and write your own code to solve it. + 2. **Faithful Execution**: After reading the skill content, strictly follow the steps in the skill guide. Do not modify the process, skip steps, or replace the skill-defined workflow with generic code. + 3. **Script Calling Standards**: Only use the `run_skill_script` tool to execute scripts explicitly required by the skill guide. The `skill_name` and `script_path` passed in must exactly match the declarations in the skill guide. Do not construct or guess paths yourself. For scripts requiring additional parameters, pass the parameters as a command-line string to `run_skill_script`. + 4. **Failure Fallback**: If `read_skill_md` returns an error or `run_skill_script` fails, explain the situation to the user and try to provide an alternative using general reasoning. + 5. **Skill Composition**: If a task requires multiple skills working together, load and execute them in logical dependency order. The output of one skill can serve as the input for the next. + {%- else %} + - No skills are currently available {%- endif %} - ### Resource Usage Requirements + ### Resource Usage Requirements {{ constraint }} ### Python Code Specifications diff --git a/backend/utils/context_utils.py b/backend/utils/context_utils.py index 740bf66df..00b98e033 100644 --- a/backend/utils/context_utils.py +++ b/backend/utils/context_utils.py @@ -266,7 +266,6 @@ def _format_skills_description( def _format_tools_description( tools: Dict[str, Any], - knowledge_base_summary: Optional[str] = None, language: str = "zh", is_manager: bool = True, ) -> str: @@ -279,10 +278,19 @@ def _format_tools_description( """ if not tools: no_tools_msg = "- 当前没有可用的工具" if language == "zh" else "- No tools are currently available" + if is_manager: + prefix = "1. 工具\n" if language == "zh" else "1. Tools\n" + return prefix + no_tools_msg return no_tools_msg lines = [] + if is_manager: + if language == "zh": + lines.append("1. 工具") + else: + lines.append("1. Tools") + if language == "zh": lines.append("- 你只能使用以下工具,不得使用任何其他工具:") else: @@ -320,15 +328,6 @@ def _format_tools_description( lines.append(f" Accepts input: {inputs}") lines.append(f" Returns output type: {output_type}") - # Knowledge base summary - if knowledge_base_summary: - if language == "zh": - lines.append("- knowledge_base_search工具只能使用以下知识库索引,请根据用户问题选择最相关的一个或多个知识库索引:") - lines.append(f" {knowledge_base_summary}") - else: - lines.append("- knowledge_base_search tool can only use the following knowledge base indexes, please select the most relevant one or more knowledge base indexes based on the user's question:") - lines.append(f" {knowledge_base_summary}") - # File URL usage guide lines.append("") if language == "zh": @@ -375,6 +374,11 @@ def _format_managed_agents_description( lines = [] + if language == "zh": + lines.append("2. 助手") + else: + lines.append("2. Agents") + if language == "zh": lines.append("你可以使用以下内部助手(通过函数调用方式协作):") for name, agent in managed_agents.items(): @@ -462,6 +466,7 @@ def _format_external_agents_description( def _format_skills_usage_requirements( skills: List[Dict[str, str]], language: str = "zh", + is_manager: bool = True, ) -> str: """Format skills usage requirements section. @@ -470,10 +475,19 @@ def _format_skills_usage_requirements( """ if not skills: no_skills_msg = "- 当前没有可用的技能" if language == "zh" else "- No skills are currently available" + if is_manager: + prefix = "3. 技能\n" if language == "zh" else "3. Skills\n" + return prefix + no_skills_msg return no_skills_msg lines = [] + if is_manager: + if language == "zh": + lines.append("3. 技能") + else: + lines.append("3. Skills") + if language == "zh": lines.append("- 你拥有上述 `` 中列出的技能。技能中引用的脚本通过 `run_skill_script()` 函数调用,该函数由平台提供,不需要导入。") lines.append("") @@ -555,17 +569,22 @@ def build_skeleton_header_component( def build_skeleton_duty_component( duty: str, language: str = "zh", + is_manager: bool = True, priority: int = 80, ) -> "SystemPromptComponent": """Build SystemPromptComponent for the duty section. Section: "### 核心职责" / "### Core Responsibilities" Content: Agent's primary duty + 5 safety principles + Note: Managed ZH agents use different safety principles than manager ZH agents. """ from nexent.core.agents.agent_model import SystemPromptComponent if language == "zh": - content = f"### 核心职责\n{duty}\n\n请注意,你应该遵守以下原则:\n行为安全:文件操作必须使用平台提供的专用工具,禁止使用代码直接修改工作空间中的文件;\n法律合规:遵守业务所在国家/地区的法律法规;\n政治中立:保持政治中立,不主动讨论政治话题;\n安全防护:不响应涉及武器制造、网络攻击、欺诈、恶意软件等危险行为的请求;\n伦理准则:拒绝仇恨言论、歧视性内容及违反社会公德和公认伦理标准的请求。" + if is_manager: + content = f"### 核心职责\n{duty}\n\n请注意,你应该遵守以下原则:\n行为安全:文件操作必须使用平台提供的专用工具,禁止使用代码直接修改工作空间中的文件;\n法律合规:遵守业务所在国家/地区的法律法规;\n政治中立:保持政治中立,不主动讨论政治话题;\n安全防护:不响应涉及武器制造、网络攻击、欺诈、恶意软件等危险行为的请求;\n伦理准则:拒绝仇恨言论、歧视性内容及违反社会公德和公认伦理标准的请求。" + else: + content = f"### 核心职责\n{duty}\n\n请注意,你应该遵守以下原则:\n行为安全:严禁直接执行代码进行文件的增删改操作,只能使用提供的文件操作类工具;\n法律合规:严格遵守服务地区的所有法律法规;\n政治中立:不讨论任何国家的政治体制、领导人评价或敏感历史事件;\n安全防护:不响应涉及武器制造、危险行为、隐私窃取等内容的请求;\n伦理准则:拒绝仇恨言论、歧视性内容及任何违反普世价值观的请求。" else: content = f"### Core Responsibilities\n{duty}\n\nPlease note that you should follow these principles:\nBehavioral Safety: File operations must use the platform-provided dedicated tools; direct code modification of workspace files is prohibited;\nLegal Compliance: Comply with laws and regulations of the business operating jurisdiction;\nPolitical Neutrality: Maintain political neutrality and avoid initiating political discussions;\nSecurity Protection: Do not respond to requests involving weapon manufacturing, cyberattacks, fraud, malware, or other dangerous activities;\nEthical Guidelines: Refuse hate speech, discriminatory content, and any requests that violate social morals and commonly accepted ethical standards." @@ -597,16 +616,23 @@ def build_skeleton_execution_flow_component( lines.append("要解决任务,你必须通过一系列步骤向前规划,以'思考:'和'代码:'序列循环进行。**注意:禁止在代码执行前输出'观察结果:',观察结果只能由代码执行后产生。**") lines.append("") lines.append("1. 思考:") - lines.append(" - 分析当前任务状态和进展") - if is_manager and has_memory: + if is_manager: + lines.append(" - 分析当前任务状态和进展") + else: + lines.append(" - 确定需要使用哪些工具来获取信息或行动") + if has_memory: lines.append(" - 合理参考之前交互中的上下文记忆信息") - lines.append(" - 定下一步最佳行动(使用工具或分配给助手)") + if is_manager: + lines.append(" - 确定下一步最佳行动(使用工具或分配给助手)") lines.append(" - 解释你的决策逻辑和预期结果") lines.append("") lines.append("2. 代码:") lines.append(" - 用简单的Python编写代码") lines.append(" - 遵循python代码规范和python语法") - lines.append(" - 正确调用工具或助手解决问题") + if is_manager: + lines.append(" - 正确调用工具或助手解决问题") + else: + lines.append(" - 根据格式规范正确调用工具") lines.append(" - 考虑到代码执行与展示用户代码的区别,使用'代码'表达运行代码,使用'代码'表达展示代码") lines.append(" - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。") lines.append(" - **重要**:代码执行后,系统会返回 \"Observation:\" 标记的内容(这是真实的执行结果)。请基于这些真实结果继续下一步思考,**不要在代码执行前自行编造观察结果**。") @@ -633,21 +659,31 @@ def build_skeleton_execution_flow_component( lines.append(" - 避免在Markdown中使用HTML标签,优先使用Markdown原生语法") lines.append(" - 代码块中的代码应保持原始格式,不要添加额外的转义字符") lines.append(" - 若未使用检索工具,则不添加任何引用标记") + if not is_manager: + lines.append("") + lines.append("注意最后生成的回答要语义连贯,信息清晰,可读性高。") else: lines = ["### Execution Process"] lines.append("To solve tasks, you must plan forward through a series of steps in a loop of 'Think:' and 'Code:' sequences. **IMPORTANT: You must NOT output 'Observe Results:' before code execution. Observation results can ONLY be generated after code execution.**") lines.append("") lines.append("1. Think:") - lines.append(" - Analyze current task status and progress") - if is_manager and has_memory: + if is_manager: + lines.append(" - Analyze current task status and progress") + else: + lines.append(" - Determine which tools need to be used to obtain information or take action") + if has_memory: lines.append(" - Reference relevant contextual memories from previous interactions when applicable") - lines.append(" - Determine the best next action (use tools or delegate to agents)") + if is_manager: + lines.append(" - Determine the best next action (use tools or delegate to agents)") lines.append(" - Explain your decision logic and expected results") lines.append("") lines.append("2. Code:") lines.append(" - Write code in simple Python") lines.append(" - Follow Python coding standards and Python syntax") - lines.append(" - Correctly call tools or agents to solve problems") + if is_manager: + lines.append(" - Correctly call tools or agents to solve problems") + else: + lines.append(" - Call tools correctly according to format specifications") lines.append(" - To distinguish between code execution and displaying user code, use 'code' for executing code and 'code' for displaying code") lines.append(" - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code.") lines.append(" - **IMPORTANT**: After code execution, the system will return content with \"Observation:\" marker (this is the real execution result). Please continue your next thinking based on these real results. **Do NOT fabricate observation results before code execution.**") @@ -674,6 +710,9 @@ def build_skeleton_execution_flow_component( lines.append(" - Avoid using HTML tags in Markdown, prioritize native Markdown syntax") lines.append(" - Code in code blocks should maintain original format, do not add extra escape characters") lines.append(" - If no retrieval tools are used, do not add any reference marks") + if not is_manager: + lines.append("") + lines.append("Note that the final generated answer should be semantically coherent, with clear information and high readability.") content = "\n".join(lines) @@ -782,6 +821,35 @@ def build_skeleton_footer_component( ) +def build_available_resources_header_component( + is_manager: bool = True, + language: str = "zh", + priority: int = 55, +) -> "SystemPromptComponent": + """Build SystemPromptComponent for the Available Resources section header. + + Manager agents get a preamble restricting resources; managed agents get only the heading. + """ + from nexent.core.agents.agent_model import SystemPromptComponent + + if language == "zh": + if is_manager: + content = "### 可用资源\n你只能使用以下资源,不得使用任何其他工具或助手:" + else: + content = "### 可用资源" + else: + if is_manager: + content = "### Available Resources\nYou can only use the following resources, and may not use any other tools or agents:" + else: + content = "### Available Resources" + + return SystemPromptComponent( + content=content, + template_name="available_resources_header", + priority=priority, + ) + + # ============================================================================= # SECTION 3: Piecewise component builders (existing, enhanced) # ============================================================================= @@ -830,7 +898,6 @@ def build_tools_component( formatted_desc = _format_tools_description( tools, - knowledge_base_summary=knowledge_base_summary, language=language, is_manager=is_manager, ) @@ -913,6 +980,7 @@ def build_knowledge_base_component( knowledge_base_summary: str, kb_ids: Optional[List[str]] = None, priority: int = 10, + language: str = "zh", ) -> "KnowledgeBaseComponent": """Build KnowledgeBaseComponent from knowledge base summary. @@ -920,14 +988,24 @@ def build_knowledge_base_component( knowledge_base_summary: Summary text from knowledge bases kb_ids: List of knowledge base IDs used priority: Component priority for selection + language: Language code ('zh' or 'en') Returns: KnowledgeBaseComponent instance """ from nexent.core.agents.agent_model import KnowledgeBaseComponent + if knowledge_base_summary: + if language == "zh": + guidance = "knowledge_base_search 工具只能使用以下知识库索引,请根据用户的问题选择最相关的一个或多个知识库索引:\n" + else: + guidance = "knowledge_base_search tool can only use the following knowledge base indexes, please select the most relevant one or more knowledge base indexes based on the user's question:\n" + prefixed_summary = guidance + knowledge_base_summary + else: + prefixed_summary = knowledge_base_summary + return KnowledgeBaseComponent( - summary=knowledge_base_summary, + summary=prefixed_summary, kb_ids=kb_ids or [], priority=priority, ) @@ -1046,9 +1124,10 @@ def build_system_prompt_component( def build_skills_usage_component( skills: List[Dict[str, str]], language: str = "zh", + is_manager: bool = True, priority: int = 40, -) -> "SystemPromptComponent": - """Build SystemPromptComponent for skills usage requirements. +) -> "SkillsComponent": + """Build SkillsComponent for skills usage requirements. This is a skeleton-like component but its content depends on whether skills exist, so it's built dynamically. @@ -1056,17 +1135,18 @@ def build_skills_usage_component( Args: skills: List of skill dicts language: Language code ('zh' or 'en') + is_manager: Whether this is a manager agent priority: Component priority Returns: - SystemPromptComponent instance + SkillsComponent instance """ - from nexent.core.agents.agent_model import SystemPromptComponent + from nexent.core.agents.agent_model import SkillsComponent - content = _format_skills_usage_requirements(skills, language=language) - return SystemPromptComponent( - content=content, - template_name="skills_usage", + content = _format_skills_usage_requirements(skills, language=language, is_manager=is_manager) + return SkillsComponent( + skills=skills, + formatted_description=content, priority=priority, ) @@ -1141,20 +1221,22 @@ def build_context_components( Piecewise assembly: Each semantic section is emitted as a dedicated ContextComponent, assembled in the exact order matching Jinja2 templates. - Assembly order (12 sections): + Assembly order (15 sections): 1. Header (基本信息) 2. Memory (上下文记忆) - if memory_list exists 3. Duty (核心职责 + 安全准则) 4. Skills (可用技能 + 6步流程) - if skills exist 5. Execution Flow (执行流程 + 输出规范) - 6. Tools (可用资源/1. 工具 + 文件链接指南) - 7. Managed Agents (可用资源/2. 助手) - if managed_agents exist - 8. External Agents (外部助手) - if external_a2a_agents exist - 9. Agent Fallback (当前没有可用的助手) - if no agents - 10. Skills Usage (可用资源/3. 技能 + 使用要求) - 11. Constraint (资源使用要求) - 12. Code Norms (python代码规范) - 13. Footer (示例模板 + 结尾) + 6. Available Resources Header (可用资源 heading) + 7. Tools (可用资源/1. 工具 + 文件链接指南) + 8. Knowledge Base (知识库) - if knowledge_base_summary exists + 9. Managed Agents (可用资源/2. 助手) - if managed_agents exist + 10. External Agents (外部助手) - if external_a2a_agents exist + 11. Agent Fallback (当前没有可用的助手) - if no agents + 12. Skills Usage (可用资源/3. 技能 + 使用要求) + 13. Constraint (资源使用要求) + 14. Code Norms (python代码规范) + 15. Footer (示例模板 + 结尾) Note: The a330d815 short-circuit (if system_prompt: return [single]) has been REMOVED. All callers must provide raw params for piecewise assembly. @@ -1215,6 +1297,7 @@ def build_context_components( build_skeleton_duty_component( duty=duty, language=language, + is_manager=is_manager, ) ) @@ -1236,7 +1319,15 @@ def build_context_components( ) ) - # 6. Tools + File URL Guide + # 6. Available Resources Header + components.append( + build_available_resources_header_component( + is_manager=is_manager, + language=language, + ) + ) + + # 7. Tools + File URL Guide if include_tools and tools: components.append( build_tools_component( @@ -1247,7 +1338,17 @@ def build_context_components( ) ) - # 7. Managed Agents (if exists) - manager only + # 8. Knowledge Base (if exists) + if include_knowledge_base and knowledge_base_summary: + components.append( + build_knowledge_base_component( + knowledge_base_summary=knowledge_base_summary, + kb_ids=kb_ids, + language=language, + ) + ) + + # 9. Managed Agents (if exists) - manager only if is_manager and include_managed_agents and managed_agents: components.append( build_managed_agents_component( @@ -1256,7 +1357,7 @@ def build_context_components( ) ) - # 8. External Agents (if exists) - manager only + # 10. External Agents (if exists) - manager only if is_manager and include_external_agents and external_a2a_agents: components.append( build_external_agents_component( @@ -1265,7 +1366,7 @@ def build_context_components( ) ) - # 9. Agent Fallback (if no agents available) - manager only + # 11. Agent Fallback (if no agents available) - manager only if is_manager and not managed_agents and not external_a2a_agents: fallback_comp = build_agent_fallback_component( managed_agents=managed_agents or {}, @@ -1275,16 +1376,17 @@ def build_context_components( if fallback_comp.content: # Only add if has content components.append(fallback_comp) - # 10. Skills Usage Requirements + # 12. Skills Usage Requirements if include_skills: components.append( build_skills_usage_component( skills=skills or [], language=language, + is_manager=is_manager, ) ) - # 11. Constraint + # 13. Constraint if constraint: components.append( build_skeleton_constraint_component( @@ -1293,7 +1395,7 @@ def build_context_components( ) ) - # 12. Code Norms + # 14. Code Norms components.append( build_skeleton_code_norms_component( language=language, @@ -1301,7 +1403,7 @@ def build_context_components( ) ) - # 13. Footer + # 15. Footer if few_shots: components.append( build_skeleton_footer_component( diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 73b13dacc..a6f3c0005 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -486,7 +486,12 @@ def run(self, task: str, stream: bool = False, reset: bool = True, images: Optio {str(additional_args)}.""" system_prompt_content = self.system_prompt - if self.context_manager and self.context_manager.get_registered_components(): + registered = self.context_manager.get_registered_components() if self.context_manager else [] + if registered: + self.logger.log( + f"ContextManager component path active: " + f"{[f'{c.component_type}(priority={c.priority},tokens={c.token_estimate})' for c in registered]}" + ) component_messages = self.context_manager.build_system_prompt() if component_messages: system_prompt_content = "\n\n".join( diff --git a/sdk/nexent/core/agents/summary_config.py b/sdk/nexent/core/agents/summary_config.py index e271ddd34..8a568af5d 100644 --- a/sdk/nexent/core/agents/summary_config.py +++ b/sdk/nexent/core/agents/summary_config.py @@ -103,7 +103,7 @@ class ContextManagerConfig: component_budgets: Dict[str, int] = field(default_factory=lambda: { "system_prompt": 4000, "tools": 3000, - "skills": 1000, + "skills": 4000, "memory": 2000, "knowledge_base": 1500, "managed_agents": 500, diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index f650de5d7..cee475e0a 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -389,8 +389,8 @@ def test_get_skills_for_template_exception_handling(self): ) assert result == [] - mock_logger.warning.assert_called_once() - assert "Failed to get skills for template: Service unavailable" in mock_logger.warning.call_args[0][0] + mock_logger.error.assert_called_once() + assert "Failed to get skills for agent" in mock_logger.error.call_args[0][0] def test_get_skills_for_template_with_version_no(self): """Test case with specific version number""" @@ -2624,7 +2624,7 @@ async def test_create_agent_config_knowledge_base_summary_error(self): await create_agent_config("agent_1", "tenant_1", "user_1", "zh", "test query") # Verify that error was logged - mock_logger.error.assert_called_with("Failed to build knowledge base summary: Test Error") + mock_logger.error.assert_any_call("Failed to build knowledge base summary: Test Error") class TestCreateModelConfigList: diff --git a/test/backend/utils/test_context_component_types.py b/test/backend/utils/test_context_component_types.py new file mode 100644 index 000000000..0ab2b12d6 --- /dev/null +++ b/test/backend/utils/test_context_component_types.py @@ -0,0 +1,659 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock + +TEST_ROOT = Path(__file__).resolve().parents[2] +PROJECT_ROOT = TEST_ROOT.parent + +for _path in (str(PROJECT_ROOT), str(TEST_ROOT)): + if _path not in sys.path: + sys.path.insert(0, _path) + +_sdk_dir = str(PROJECT_ROOT / "sdk") +if _sdk_dir not in sys.path: + sys.path.insert(0, _sdk_dir) + +_mem0_stubs = { + "mem0": MagicMock(), + "mem0.memory": MagicMock(), + "mem0.memory.main": MagicMock(), + "mem0.embeddings": MagicMock(), + "mem0.embeddings.base": MagicMock(), + "mem0.configs": MagicMock(), + "mem0.configs.embeddings": MagicMock(), + "mem0.configs.embeddings.base": MagicMock(), + "smolagents": MagicMock(), + "smolagents.memory": MagicMock(), + "smolagents.agents": MagicMock(), + "smolagents.tools": MagicMock(), + "smolagents.models": MagicMock(), + "smolagents.local_python_executor": MagicMock(), + "smolagents.utils": MagicMock(), + "smolagents.monitoring": MagicMock(), + "openai": MagicMock(), + "openai.types": MagicMock(), + "openai.types.chat": MagicMock(), + "openai.types.chat.chat_completion_message": MagicMock(), + "openai.types.chat.chat_completion": MagicMock(), + "openai.types.chat.completion_create_params": MagicMock(), + "tiktoken": MagicMock(), + "tiktoken.encoding_for_model": MagicMock(), + "websockets": MagicMock(), + "websockets.client": MagicMock(), + "websockets.server": MagicMock(), + "dashscope": MagicMock(), + "dashscope.audio": MagicMock(), + "dashscope.audio.asr": MagicMock(), + "requests": MagicMock(), + "requests.exceptions": MagicMock(), + "boto3": MagicMock(), + "boto3.exceptions": MagicMock(), + "botocore": MagicMock(), + "botocore.exceptions": MagicMock(), + "botocore.client": MagicMock(), + "minio": MagicMock(), + "minio.error": MagicMock(), + "docker": MagicMock(), + "docker.errors": MagicMock(), + "docker.types": MagicMock(), + "fastmcp": MagicMock(), + "fastmcp.client": MagicMock(), + "fastmcp.client.transports": MagicMock(), + "kubernetes": MagicMock(), + "kubernetes.client": MagicMock(), + "kubernetes.config": MagicMock(), + "rich": MagicMock(), + "rich.console": MagicMock(), + "rich.markdown": MagicMock(), + "rich.panel": MagicMock(), + "rich.text": MagicMock(), +} +for _mod, _mock in _mem0_stubs.items(): + if _mod not in sys.modules: + sys.modules[_mod] = _mock + +_nexent_sub_stubs = { + "nexent.memory": MagicMock(), + "nexent.memory.memory_core": MagicMock(), + "nexent.memory.memory_service": MagicMock(), + "nexent.memory.embedder_adaptor": MagicMock(), + "nexent.datamate": MagicMock(), + "nexent.datamate.datamate_client": MagicMock(), + "nexent.storage": MagicMock(), + "nexent.storage.storage_client_factory": MagicMock(), + "nexent.storage.minio": MagicMock(), + "nexent.storage.local": MagicMock(), + "nexent.container": MagicMock(), + "nexent.container.container_client_factory": MagicMock(), + "nexent.container.docker_client": MagicMock(), + "nexent.container.k8s_client": MagicMock(), + "nexent.core.models": MagicMock(), + "nexent.core.models.openai_llm": MagicMock(), + "nexent.core.models.openai_long_context_model": MagicMock(), + "nexent.core.models.embedding_model": MagicMock(), + "nexent.core.models.ali_stt_model": MagicMock(), + "nexent.core.agents.core_agent": MagicMock(), + "nexent.core.agents.agent_context": MagicMock(), + "nexent.core.agents.summary_cache": MagicMock(), + "nexent.core.agents.summary_config": MagicMock(), + "nexent.skills": MagicMock(), + "nexent.skills.skill_loader": MagicMock(), +} +for _mod, _mock in _nexent_sub_stubs.items(): + if _mod not in sys.modules: + sys.modules[_mod] = _mock + +import pytest + + +class TestBuilderReturnTypes: + def test_build_skeleton_header_returns_system_prompt(self): + from backend.utils.context_utils import build_skeleton_header_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_skeleton_header_component( + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + ) + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_skeleton_duty_returns_system_prompt(self): + from backend.utils.context_utils import build_skeleton_duty_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_skeleton_duty_component(duty="Help users.") + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_skeleton_execution_flow_returns_system_prompt(self): + from backend.utils.context_utils import build_skeleton_execution_flow_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_skeleton_execution_flow_component() + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_skeleton_constraint_returns_system_prompt(self): + from backend.utils.context_utils import build_skeleton_constraint_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_skeleton_constraint_component(constraint="Be helpful.") + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_skeleton_code_norms_returns_system_prompt(self): + from backend.utils.context_utils import build_skeleton_code_norms_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_skeleton_code_norms_component() + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_skeleton_footer_returns_system_prompt(self): + from backend.utils.context_utils import build_skeleton_footer_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_skeleton_footer_component(few_shots="Q: hi? A: Hello!") + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_tools_returns_tools_component(self): + from backend.utils.context_utils import build_tools_component + from nexent.core.agents.agent_model import ToolsComponent + + comp = build_tools_component(tools={}) + assert isinstance(comp, ToolsComponent) + assert comp.component_type == "tools" + + def test_build_skills_returns_skills_component(self): + from backend.utils.context_utils import build_skills_component + from nexent.core.agents.agent_model import SkillsComponent + + comp = build_skills_component( + skills=[{"name": "test", "description": "desc"}] + ) + assert isinstance(comp, SkillsComponent) + assert comp.component_type == "skills" + + def test_build_memory_returns_memory_component(self): + from backend.utils.context_utils import build_memory_component + from nexent.core.agents.agent_model import MemoryComponent + + comp = build_memory_component( + memory_list=[{"memory": "test", "score": 0.9, "memory_level": "user"}] + ) + assert isinstance(comp, MemoryComponent) + assert comp.component_type == "memory" + + def test_build_knowledge_base_returns_kb_component(self): + from backend.utils.context_utils import build_knowledge_base_component + from nexent.core.agents.agent_model import KnowledgeBaseComponent + + comp = build_knowledge_base_component( + knowledge_base_summary="KB text", kb_ids=["kb-1"] + ) + assert isinstance(comp, KnowledgeBaseComponent) + assert comp.component_type == "knowledge_base" + + def test_build_managed_agents_returns_managed_component(self): + from backend.utils.context_utils import build_managed_agents_component + from nexent.core.agents.agent_model import ManagedAgentsComponent + + comp = build_managed_agents_component(managed_agents={}) + assert isinstance(comp, ManagedAgentsComponent) + assert comp.component_type == "managed_agents" + + def test_build_external_agents_returns_external_component(self): + from backend.utils.context_utils import build_external_agents_component + from nexent.core.agents.agent_model import ExternalAgentsComponent + + comp = build_external_agents_component(external_a2a_agents={}) + assert isinstance(comp, ExternalAgentsComponent) + assert comp.component_type == "external_a2a_agents" + + def test_build_skills_usage_returns_skills_component(self): + from backend.utils.context_utils import build_skills_usage_component + from nexent.core.agents.agent_model import SkillsComponent + + comp = build_skills_usage_component( + skills=[{"name": "test", "description": "desc"}] + ) + assert isinstance(comp, SkillsComponent) + assert comp.component_type == "skills" + + def test_build_agent_fallback_returns_system_prompt(self): + from backend.utils.context_utils import build_agent_fallback_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_agent_fallback_component( + managed_agents={}, external_a2a_agents={} + ) + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_build_available_resources_header_returns_system_prompt(self): + from backend.utils.context_utils import build_available_resources_header_component + from nexent.core.agents.agent_model import SystemPromptComponent + + comp = build_available_resources_header_component() + assert isinstance(comp, SystemPromptComponent) + assert comp.component_type == "system_prompt" + + def test_execution_flow_managed_text(self): + from backend.utils.context_utils import build_skeleton_execution_flow_component + + comp = build_skeleton_execution_flow_component(is_manager=False, language="zh") + assert "确定需要使用哪些工具" in comp.content + assert "注意最后生成的回答要语义连贯" in comp.content + + def test_execution_flow_manager_text(self): + from backend.utils.context_utils import build_skeleton_execution_flow_component + + comp = build_skeleton_execution_flow_component(is_manager=True, language="zh") + assert "分析当前任务状态和进展" in comp.content + assert "分配给助手" in comp.content + + def test_duty_managed_zh(self): + from backend.utils.context_utils import build_skeleton_duty_component + + comp = build_skeleton_duty_component(duty="test", is_manager=False, language="zh") + assert "严禁直接执行代码" in comp.content + + def test_duty_manager_zh(self): + from backend.utils.context_utils import build_skeleton_duty_component + + comp = build_skeleton_duty_component(duty="test", is_manager=True, language="zh") + assert "文件操作必须使用平台提供的专用工具" in comp.content + + def test_kb_not_duplicated_in_tools(self): + from backend.utils.context_utils import build_tools_component + + class MockTool: + name = "t" + description = "Test tool" + inputs = "{}" + output_type = "str" + source = "local" + + comp = build_tools_component( + tools={"t": MockTool()}, + knowledge_base_summary="KB text", + ) + assert "KB text" not in comp.formatted_description + + def test_available_resources_header_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 + + def test_available_resources_header_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 comp.content == "### 可用资源" + + +class TestBuildContextComponentsAssembly: + def test_knowledge_base_included_when_flag_true_and_summary_exists(self): + from backend.utils.context_utils import build_context_components + + components = build_context_components( + duty="Help users.", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + include_knowledge_base=True, + knowledge_base_summary="KB text", + kb_ids=["kb-1"], + ) + types = [c.component_type for c in components] + assert "knowledge_base" in types + + def test_knowledge_base_excluded_when_flag_false(self): + from backend.utils.context_utils import build_context_components + + components = build_context_components( + duty="Help users.", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + include_knowledge_base=False, + knowledge_base_summary="KB text", + kb_ids=["kb-1"], + ) + types = [c.component_type for c in components] + assert "knowledge_base" not in types + + def test_knowledge_base_excluded_when_summary_empty(self): + from backend.utils.context_utils import build_context_components + + components = build_context_components( + duty="Help users.", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + include_knowledge_base=True, + knowledge_base_summary="", + kb_ids=["kb-1"], + ) + types = [c.component_type for c in components] + assert "knowledge_base" not in types + + def test_skills_usage_has_skills_type(self): + from backend.utils.context_utils import build_context_components + + components = build_context_components( + duty="Help users.", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + skills=[{"name": "s1", "description": "d1"}], + ) + skills_components = [c for c in components if c.component_type == "skills"] + assert len(skills_components) >= 1 + skills_usage = [ + c + for c in skills_components + if hasattr(c, "skills") and c.skills == [{"name": "s1", "description": "d1"}] + ] + assert len(skills_usage) >= 1 + assert skills_usage[0].component_type == "skills" + + def test_all_component_types_present_with_full_inputs(self): + from backend.utils.context_utils import build_context_components + + class MockTool: + name = "tool1" + description = "Test tool" + inputs = "{}" + output_type = "str" + source = "local" + + class MockManagedAgent: + name = "agent1" + description = "Test agent" + + class MockExternalAgent: + agent_id = "ext-1" + name = "External" + description = "External agent" + + components = build_context_components( + duty="Help users.", + constraint="Be helpful.", + few_shots="Q: hi? A: Hello!", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + is_manager=True, + tools={"tool1": MockTool()}, + skills=[{"name": "s1", "description": "d1"}], + managed_agents={"agent1": MockManagedAgent()}, + external_a2a_agents={"ext-1": MockExternalAgent()}, + memory_list=[{"memory": "test", "score": 0.9, "memory_level": "user"}], + knowledge_base_summary="KB text", + kb_ids=["kb-1"], + ) + types = [c.component_type for c in components] + assert "system_prompt" in types + assert "memory" in types + assert "skills" in types + assert "tools" in types + assert "managed_agents" in types + assert "external_a2a_agents" in types + + def test_component_order_preserved(self): + from backend.utils.context_utils import build_context_components + + class MockTool: + name = "tool1" + description = "Test tool" + inputs = "{}" + output_type = "str" + source = "local" + + class MockManagedAgent: + name = "agent1" + description = "Test agent" + + class MockExternalAgent: + agent_id = "ext-1" + name = "External" + description = "External agent" + + components = build_context_components( + duty="Help users.", + constraint="Be helpful.", + few_shots="Q: hi? A: Hello!", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + is_manager=True, + tools={"tool1": MockTool()}, + skills=[{"name": "s1", "description": "d1"}], + managed_agents={"agent1": MockManagedAgent()}, + external_a2a_agents={"ext-1": MockExternalAgent()}, + memory_list=[{"memory": "test", "score": 0.9, "memory_level": "user"}], + knowledge_base_summary="KB text", + kb_ids=["kb-1"], + ) + types = [c.component_type for c in components] + expected_order = [ + "system_prompt", + "memory", + "system_prompt", + "skills", + "system_prompt", + "system_prompt", + "tools", + "knowledge_base", + "managed_agents", + "external_a2a_agents", + "skills", + "system_prompt", + "system_prompt", + "system_prompt", + ] + assert types == expected_order + + def test_kb_ids_passed_through(self): + from backend.utils.context_utils import build_context_components + from nexent.core.agents.agent_model import KnowledgeBaseComponent + + components = build_context_components( + duty="Help users.", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + kb_ids=["kb-1", "kb-2"], + knowledge_base_summary="text", + ) + kb_components = [ + c for c in components if isinstance(c, KnowledgeBaseComponent) + ] + assert len(kb_components) >= 1 + assert kb_components[0].kb_ids == ["kb-1", "kb-2"] + + +class TestComponentToMessages: + def test_skills_component_to_messages(self): + from nexent.core.agents.agent_model import SkillsComponent + + comp = SkillsComponent( + skills=[{"name": "test", "description": "desc"}], + formatted_description="test desc", + ) + messages = comp.to_messages() + assert messages == [{"role": "system", "content": "test desc"}] + + def test_knowledge_base_component_to_messages(self): + from nexent.core.agents.agent_model import KnowledgeBaseComponent + + comp = KnowledgeBaseComponent(summary="KB summary") + messages = comp.to_messages() + assert messages == [{"role": "system", "content": "KB summary"}] + + def test_knowledge_base_component_empty_summary_no_messages(self): + from nexent.core.agents.agent_model import KnowledgeBaseComponent + + comp = KnowledgeBaseComponent(summary="") + messages = comp.to_messages() + assert messages == [] + + def test_memory_component_to_messages(self): + from nexent.core.agents.agent_model import MemoryComponent + + comp = MemoryComponent(formatted_content="memory text") + messages = comp.to_messages() + assert messages == [{"role": "system", "content": "memory text"}] + + def test_tools_component_to_messages(self): + from nexent.core.agents.agent_model import ToolsComponent + + comp = ToolsComponent(formatted_description="tools text") + messages = comp.to_messages() + assert messages == [{"role": "system", "content": "tools text"}] + + +class TestFullPromptAssembly: + def test_full_assembly_produces_system_messages(self): + from backend.utils.context_utils import build_context_components + + class MockTool: + name = "tool1" + description = "Test tool" + inputs = "{}" + output_type = "str" + source = "local" + + class MockManagedAgent: + name = "agent1" + description = "Test agent" + + class MockExternalAgent: + agent_id = "ext-1" + name = "External" + description = "External agent" + + components = build_context_components( + duty="Help users.", + constraint="Be helpful.", + few_shots="Q: hi? A: Hello!", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + is_manager=True, + tools={"tool1": MockTool()}, + skills=[{"name": "s1", "description": "d1"}], + managed_agents={"agent1": MockManagedAgent()}, + external_a2a_agents={"ext-1": MockExternalAgent()}, + memory_list=[{"memory": "test", "score": 0.9, "memory_level": "user"}], + knowledge_base_summary="KB text", + kb_ids=["kb-1"], + ) + all_messages = [] + for comp in components: + all_messages.extend(comp.to_messages()) + assert len(all_messages) > 0 + for msg in all_messages: + assert msg["role"] == "system" + assert msg["content"] + + def test_full_assembly_contains_key_sections(self): + from backend.utils.context_utils import build_context_components + + components = build_context_components( + duty="Help users.", + constraint="Be helpful.", + few_shots="Q: hi? A: Hello!", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + is_manager=True, + ) + all_messages = [] + for comp in components: + all_messages.extend(comp.to_messages()) + combined = "\n".join(msg["content"] for msg in all_messages) + assert "\u57fa\u672c\u4fe1\u606f" in combined or "Basic Information" in combined + assert "\u6838\u5fc3\u804c\u8d23" in combined or "Core Responsibilities" in combined + assert "\u6267\u884c\u6d41\u7a0b" in combined or "Execution Process" in combined + assert "python\u4ee3\u7801\u89c4\u8303" in combined or "Python Code Specifications" in combined + assert "\u53ef\u7528\u8d44\u6e90" in combined or "Available Resources" in combined + + def test_english_language_produces_english_content(self): + from backend.utils.context_utils import build_context_components + + components = build_context_components( + duty="Help users.", + constraint="Be helpful.", + few_shots="Q: hi? A: Hello!", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + is_manager=True, + language="en", + ) + all_messages = [] + for comp in components: + all_messages.extend(comp.to_messages()) + combined = "\n".join(msg["content"] for msg in all_messages) + assert "Basic Information" in combined + assert "Core Responsibilities" in combined + assert "Execution Process" in combined + + def test_component_count_matches_expected(self): + from backend.utils.context_utils import build_context_components + + class MockTool: + name = "tool1" + description = "Test tool" + inputs = "{}" + output_type = "str" + source = "local" + + class MockManagedAgent: + name = "agent1" + description = "Test agent" + + class MockExternalAgent: + agent_id = "ext-1" + name = "External" + description = "External agent" + + components = build_context_components( + duty="Help users.", + constraint="Be helpful.", + few_shots="Q: hi? A: Hello!", + app_name="Test", + app_description="Desc", + time_str="2026-01-01", + user_id="u1", + is_manager=True, + tools={"tool1": MockTool()}, + skills=[{"name": "s1", "description": "d1"}], + managed_agents={"agent1": MockManagedAgent()}, + external_a2a_agents={"ext-1": MockExternalAgent()}, + memory_list=[{"memory": "test", "score": 0.9, "memory_level": "user"}], + knowledge_base_summary="KB text", + kb_ids=["kb-1"], + ) + assert len(components) == 14 + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/test/backend/utils/test_context_utils.py b/test/backend/utils/test_context_utils.py index 66e789477..26c2a20d8 100644 --- a/test/backend/utils/test_context_utils.py +++ b/test/backend/utils/test_context_utils.py @@ -14,6 +14,11 @@ class TestFormatFunctions: def test_format_tools_empty(self): from backend.utils.context_utils import _format_tools_description result = _format_tools_description({}, language="zh") + assert result == "1. 工具\n- 当前没有可用的工具" + + def test_format_tools_empty_managed(self): + from backend.utils.context_utils import _format_tools_description + result = _format_tools_description({}, language="zh", is_manager=False) assert result == "- 当前没有可用的工具" def test_format_tools_single(self): @@ -130,7 +135,8 @@ def test_build_knowledge_base_component_empty(self): def test_build_knowledge_base_component_with_summary(self): from backend.utils.context_utils import build_knowledge_base_component comp = build_knowledge_base_component("KB text", kb_ids=["kb-1"]) - assert comp.summary == "KB text" + assert "KB text" in comp.summary + assert "knowledge_base_search" in comp.summary def test_build_managed_agents_component_empty(self): from backend.utils.context_utils import build_managed_agents_component From de646c048dfcb88a5055f17695107c9fd6e6e90e Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 9 Jun 2026 18:18:56 +0800 Subject: [PATCH 2/2] fix: resolve dual ContextManager bug and enable context manager by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add atomic replace_components() method to ContextManager to prevent race conditions when swapping components on conversation-level CM - Fix run_agent.py to re-register components on surviving CM after overwrite (both MCP and non-MCP paths) - Guard CM creation in nexent_agent.py with enabled check to avoid creating useless CM when context management is disabled - Change enable_context_manager default from False to True - Fix numbering consistency: tools and skills always show 1./3. prefix - Fix indentation in manager_system_prompt_template_en.yaml (6→5 spaces) - Add tests for replace_components() and component survival after overwrite --- backend/agents/create_agent_info.py | 2 +- backend/database/db_models.py | 2 +- .../manager_system_prompt_template_en.yaml | 6 +- backend/utils/context_utils.py | 30 +++---- sdk/nexent/core/agents/agent_context.py | 20 +++++ sdk/nexent/core/agents/nexent_agent.py | 4 +- sdk/nexent/core/agents/run_agent.py | 4 + test/backend/database/test_agent_db.py | 2 +- test/backend/utils/test_context_utils.py | 2 +- .../unit/test_component_management.py | 47 ++++++++++ ...test_nexent_agent_component_integration.py | 85 ++++++++++++++++++- 11 files changed, 175 insertions(+), 29 deletions(-) diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index fbb009a38..8158d652d 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -522,7 +522,7 @@ async def create_agent_config( # downstream runtime may prefer component-based prompt assembly over the # rendered system_prompt, causing the actual model input to diverge from the # template output. - enable_context_manager = agent_info.get("enable_context_manager", False) + enable_context_manager = agent_info.get("enable_context_manager", True) context_components = [] if enable_context_manager: context_components = build_context_components( diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 8a20e9003..14ba47bc0 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -332,7 +332,7 @@ class AgentInfo(TableBase): is_new = Column(Boolean, default=False, doc="Whether this agent is marked as new for the user") current_version_no = Column(Integer, nullable=True, doc="Current published version number. NULL means no version published yet") ingroup_permission = Column(String(30), doc="In-group permission: EDIT, READ_ONLY, PRIVATE") - enable_context_manager = Column(Boolean, default=False, doc="Whether to enable context management (compression) for this agent") + enable_context_manager = Column(Boolean, default=True, doc="Whether to enable context management (compression) for this agent") greeting_message = Column(Text, doc="Agent greeting message displayed on chat initial screen") example_questions = Column(JSONB, doc="List of example questions for starting a conversation with this agent") diff --git a/backend/prompts/manager_system_prompt_template_en.yaml b/backend/prompts/manager_system_prompt_template_en.yaml index ede19a666..655bf7cb2 100644 --- a/backend/prompts/manager_system_prompt_template_en.yaml +++ b/backend/prompts/manager_system_prompt_template_en.yaml @@ -222,9 +222,9 @@ system_prompt: |- 3. Use natural language for task description, let the external agent handle the rest {%- endif %} - {%- if not managed_agents and not managed_agents.values() | list and not external_a2a_agents and not external_a2a_agents.values() | list %} - - No agents are currently available - {%- endif %} + {%- if not managed_agents and not managed_agents.values() | list and not external_a2a_agents and not external_a2a_agents.values() | list %} + - No agents are currently available + {%- endif %} 3. Skills {%- if skills and skills|length > 0 %} diff --git a/backend/utils/context_utils.py b/backend/utils/context_utils.py index 00b98e033..517cc3a46 100644 --- a/backend/utils/context_utils.py +++ b/backend/utils/context_utils.py @@ -278,18 +278,15 @@ def _format_tools_description( """ if not tools: no_tools_msg = "- 当前没有可用的工具" if language == "zh" else "- No tools are currently available" - if is_manager: - prefix = "1. 工具\n" if language == "zh" else "1. Tools\n" - return prefix + no_tools_msg - return no_tools_msg + prefix = "1. 工具\n" if language == "zh" else "1. Tools\n" + return prefix + no_tools_msg lines = [] - if is_manager: - if language == "zh": - lines.append("1. 工具") - else: - lines.append("1. Tools") + if language == "zh": + lines.append("1. 工具") + else: + lines.append("1. Tools") if language == "zh": lines.append("- 你只能使用以下工具,不得使用任何其他工具:") @@ -475,18 +472,15 @@ def _format_skills_usage_requirements( """ if not skills: no_skills_msg = "- 当前没有可用的技能" if language == "zh" else "- No skills are currently available" - if is_manager: - prefix = "3. 技能\n" if language == "zh" else "3. Skills\n" - return prefix + no_skills_msg - return no_skills_msg + prefix = "3. 技能\n" if language == "zh" else "3. Skills\n" + return prefix + no_skills_msg lines = [] - if is_manager: - if language == "zh": - lines.append("3. 技能") - else: - lines.append("3. Skills") + if language == "zh": + lines.append("3. 技能") + else: + lines.append("3. Skills") if language == "zh": lines.append("- 你拥有上述 `` 中列出的技能。技能中引用的脚本通过 `run_skill_script()` 函数调用,该函数由平台提供,不需要导入。") diff --git a/sdk/nexent/core/agents/agent_context.py b/sdk/nexent/core/agents/agent_context.py index 0b40d325c..6cb683a45 100644 --- a/sdk/nexent/core/agents/agent_context.py +++ b/sdk/nexent/core/agents/agent_context.py @@ -1343,6 +1343,26 @@ def get_registered_components(self) -> List: with self._lock: return list(self._components) + def replace_components(self, components: List) -> None: + """Atomically replace all registered components. + + Clears existing components and registers new ones under a single + lock acquisition, preventing race conditions when the ContextManager + is shared across concurrent runs (e.g., conversation-level CM reuse). + + Args: + components: List of ContextComponent instances to register. + Pass empty list to clear all components. + """ + with self._lock: + self._components.clear() + for component in components: + if component.token_estimate == 0: + component.token_estimate = component.estimate_tokens( + self.config.chars_per_token + ) + self._components.append(component) + def _get_strategy(self): """Factory method to get strategy instance based on config.""" from .agent_model import ( diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index b3c5b8cd0..907efe5e6 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -429,9 +429,9 @@ def create_single_agent(self, agent_config: AgentConfig): ) agent.stop_event = self.stop_event - # Mount context manager if config provided + # Mount context manager if config provided and enabled ctx_config = getattr(agent_config, 'context_manager_config', None) - if ctx_config: + if ctx_config and ctx_config.enabled: agent.context_manager = ContextManager( config=ctx_config, max_steps=agent_config.max_steps diff --git a/sdk/nexent/core/agents/run_agent.py b/sdk/nexent/core/agents/run_agent.py index 243ca099e..69facc5cd 100644 --- a/sdk/nexent/core/agents/run_agent.py +++ b/sdk/nexent/core/agents/run_agent.py @@ -88,6 +88,8 @@ def agent_run_thread(agent_run_info: AgentRunInfo): if getattr(agent_run_info, 'context_manager', None) is not None: agent.context_manager = agent_run_info.context_manager + context_components = getattr(agent_run_info.agent_config, 'context_components', None) + agent.context_manager.replace_components(context_components or []) nexent.add_history_to_agent(agent_run_info.history) nexent.agent_run_with_observer( @@ -109,6 +111,8 @@ def agent_run_thread(agent_run_info: AgentRunInfo): if getattr(agent_run_info, 'context_manager', None) is not None: agent.context_manager = agent_run_info.context_manager + context_components = getattr(agent_run_info.agent_config, 'context_components', None) + agent.context_manager.replace_components(context_components or []) nexent.add_history_to_agent(agent_run_info.history) nexent.agent_run_with_observer( diff --git a/test/backend/database/test_agent_db.py b/test/backend/database/test_agent_db.py index 336cb031e..b834180cf 100644 --- a/test/backend/database/test_agent_db.py +++ b/test/backend/database/test_agent_db.py @@ -123,7 +123,7 @@ def __init__(self): self.prompt_template_name = None self.group_ids = None self.is_new = True - self.enable_context_manager = False + self.enable_context_manager = True self.greeting_message = None self.example_questions = None self.current_version_no = None diff --git a/test/backend/utils/test_context_utils.py b/test/backend/utils/test_context_utils.py index 26c2a20d8..0debf20de 100644 --- a/test/backend/utils/test_context_utils.py +++ b/test/backend/utils/test_context_utils.py @@ -19,7 +19,7 @@ def test_format_tools_empty(self): def test_format_tools_empty_managed(self): from backend.utils.context_utils import _format_tools_description result = _format_tools_description({}, language="zh", is_manager=False) - assert result == "- 当前没有可用的工具" + assert result == "1. 工具\n- 当前没有可用的工具" def test_format_tools_single(self): from backend.utils.context_utils import _format_tools_description 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 5f25e1119..8e4304044 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 @@ -98,6 +98,53 @@ def test_clear_allows_new_registration(self): assert cm.get_registered_components()[0]._content == "new" +class TestReplaceComponents: + """Tests for replace_components() atomic swap method.""" + + def test_replace_on_empty_manager(self): + cm = ContextManager() + cm.replace_components([MockComponent(content="new1"), MockComponent(content="new2")]) + assert len(cm.get_registered_components()) == 2 + + def test_replace_clears_existing(self): + cm = ContextManager() + cm.register_component(MockComponent(content="old1")) + cm.register_component(MockComponent(content="old2")) + cm.replace_components([MockComponent(content="new")]) + registered = cm.get_registered_components() + assert len(registered) == 1 + assert registered[0]._content == "new" + + def test_replace_with_empty_list(self): + cm = ContextManager() + cm.register_component(MockComponent(content="old")) + cm.replace_components([]) + assert cm.get_registered_components() == [] + + def test_replace_estimates_tokens(self): + cm = ContextManager() + comp = MockComponent(content="some content here", token_estimate=0) + cm.replace_components([comp]) + assert cm.get_registered_components()[0].token_estimate > 0 + + def test_replace_preserves_existing_token_estimate(self): + cm = ContextManager() + comp = MockComponent(content="x", token_estimate=42) + cm.replace_components([comp]) + assert cm.get_registered_components()[0].token_estimate == 42 + + def test_replace_preserves_order(self): + cm = ContextManager() + comps = [ + MockComponent(content="first", priority=10), + MockComponent(content="second", priority=20), + MockComponent(content="third", priority=30), + ] + cm.replace_components(comps) + registered = cm.get_registered_components() + assert [c._content for c in registered] == ["first", "second", "third"] + + class TestGetRegisteredComponents: """Tests for get_registered_components() method.""" 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 49483d94b..acd31f584 100644 --- a/test/sdk/core/agents/test_nexent_agent_component_integration.py +++ b/test/sdk/core/agents/test_nexent_agent_component_integration.py @@ -29,6 +29,7 @@ def mock_context_manager(self): @pytest.fixture def agent_config_with_components(self): ctx_config = ContextManagerConfig( + enabled=True, token_threshold=1000, strategy=STRATEGY_TOKEN_BUDGET, component_budgets={"tools": 200, "skills": 100}, @@ -53,7 +54,7 @@ def test_context_manager_mounted_when_config_present(self, agent_config_with_com agent.context_manager = None ctx_config = getattr(agent_config_with_components, 'context_manager_config', None) - if ctx_config: + if ctx_config and ctx_config.enabled: from sdk.nexent.core.agents.agent_context import ContextManager agent.context_manager = ContextManager( config=ctx_config, @@ -83,6 +84,26 @@ def test_no_context_manager_when_config_absent(self): assert ctx_config is None assert agent.context_manager is None + def test_no_context_manager_when_config_disabled(self): + ctx_config = ContextManagerConfig(enabled=False, token_threshold=1000) + agent_config = AgentConfig( + name="test_agent", + description="Test agent", + model_name="test-model", + tools=[], + context_manager_config=ctx_config, + ) + + agent = MagicMock() + agent.context_manager = None + + config = getattr(agent_config, 'context_manager_config', None) + if config and config.enabled: + from sdk.nexent.core.agents.agent_context import ContextManager + agent.context_manager = ContextManager(config=config, max_steps=10) + + assert agent.context_manager is None + def test_components_registered_in_order(self, mock_context_manager, agent_config_with_components): components = getattr(agent_config_with_components, 'context_components', []) @@ -196,4 +217,64 @@ def test_context_manager_config_without_strategy_defaults(self): config = ContextManagerConfig(token_threshold=2000) assert config.strategy == STRATEGY_TOKEN_BUDGET - assert "system_prompt" in config.component_budgets \ No newline at end of file + assert "system_prompt" in config.component_budgets + + +class TestConversationLevelCMComponentSurvival: + """Tests verifying components survive conversation-level CM overwrite.""" + + def test_replace_components_after_overwrite(self): + from sdk.nexent.core.agents.agent_context import ContextManager + + conversation_cm = ContextManager( + config=ContextManagerConfig(enabled=True, token_threshold=1000), + max_steps=10, + ) + assert conversation_cm.get_registered_components() == [] + + components = [ + ToolsComponent(content="Tool descriptions", token_estimate=50), + SystemPromptComponent(content="System prompt", token_estimate=100), + ] + + conversation_cm.replace_components(components) + + registered = conversation_cm.get_registered_components() + assert len(registered) == 2 + assert registered[0].component_type == "tools" + assert registered[1].component_type == "system_prompt" + + def test_replace_components_clears_stale(self): + from sdk.nexent.core.agents.agent_context import ContextManager + + conversation_cm = ContextManager( + config=ContextManagerConfig(enabled=True, token_threshold=1000), + max_steps=10, + ) + conversation_cm.register_component( + ToolsComponent(content="stale tools", token_estimate=50) + ) + assert len(conversation_cm.get_registered_components()) == 1 + + new_components = [ + SystemPromptComponent(content="fresh prompt", token_estimate=100), + ] + conversation_cm.replace_components(new_components) + + registered = conversation_cm.get_registered_components() + assert len(registered) == 1 + assert registered[0].component_type == "system_prompt" + + def test_replace_components_with_empty_list(self): + from sdk.nexent.core.agents.agent_context import ContextManager + + conversation_cm = ContextManager( + config=ContextManagerConfig(enabled=True, token_threshold=1000), + max_steps=10, + ) + conversation_cm.register_component( + ToolsComponent(content="tools", token_estimate=50) + ) + + conversation_cm.replace_components([]) + assert conversation_cm.get_registered_components() == [] \ No newline at end of file